diff --git a/backend/ControlCenter.Api.csproj b/backend/ControlCenter.Api.csproj index d2896da..a509f52 100644 --- a/backend/ControlCenter.Api.csproj +++ b/backend/ControlCenter.Api.csproj @@ -6,13 +6,19 @@ enable + + + + + - - runtime; build; native; contentfiles; analyzers; buildtransitive - all + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/backend/ControlCenter/Hubs/AgentStatusHub.cs b/backend/ControlCenter/Hubs/AgentStatusHub.cs index f0d4497..c790b29 100644 --- a/backend/ControlCenter/Hubs/AgentStatusHub.cs +++ b/backend/ControlCenter/Hubs/AgentStatusHub.cs @@ -37,6 +37,33 @@ public class AgentStatusHub : Hub _logger = logger; } + /// + /// Broadcasts an agent status update to all connected clients. + /// + /// + /// Any connected client (or server-side caller) can invoke this method + /// to push a status update to every subscriber. The DTO is converted to + /// an record and relayed through the + /// callback. + /// + /// + /// The agent status update DTO to broadcast. + public async Task SendStatusUpdate(AgentStatusUpdateDto update) + { + _logger.LogInformation( + "Broadcasting status update for agent {AgentId}: {Status}", + update.AgentId, update.Status); + + var agentUpdate = update.ToUpdate(); + + // Broadcast to all connected clients + await Clients.All.AgentStatusChanged(agentUpdate); + + // Also push to the specific agent's group + var agentGroup = AgentGroupName(update.AgentId); + await Clients.Group(agentGroup).AgentStatusChanged(agentUpdate); + } + /// /// Adds the calling connection to the fleet group. /// Once joined, the client will receive all agent status changes diff --git a/backend/ControlCenter/Hubs/Models/AgentStatusModels.cs b/backend/ControlCenter/Hubs/Models/AgentStatusModels.cs index 3c9c97d..3edb603 100644 --- a/backend/ControlCenter/Hubs/Models/AgentStatusModels.cs +++ b/backend/ControlCenter/Hubs/Models/AgentStatusModels.cs @@ -72,6 +72,80 @@ public record TaskProgressUpdate( string? Elapsed ); +/// +/// Data transfer object for broadcasting agent status updates +/// to all connected SignalR clients via the hub's SendStatusUpdate method. +/// +/// This DTO provides a mutable, serialization-friendly alternative to +/// for callers that construct updates +/// from external data sources (e.g., HTTP API payloads). +/// +public class AgentStatusUpdateDto +{ + /// + /// Agent identifier, e.g. "otto", "dex", "rex". + /// + public string AgentId { get; set; } = string.Empty; + + /// + /// Human-readable display name, e.g. "Otto", "Dex". + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// Role description, e.g. "Orchestrator Agent", "Backend Specialist". + /// + public string Role { get; set; } = string.Empty; + + /// + /// Current operational status of the agent as lowercase string: + /// "active", "idle", "thinking", "error". + /// + public string Status { get; set; } = string.Empty; + + /// + /// Description of the agent's current task, if any. + /// + public string? CurrentTask { get; set; } + + /// + /// Full session key, e.g. "agent:otto:telegram:direct:8787451565". + /// + public string SessionKey { get; set; } = string.Empty; + + /// + /// Communication channel, e.g. "telegram", "discord", "slack". + /// + public string Channel { get; set; } = string.Empty; + + /// + /// ISO 8601 timestamp of the agent's last activity. + /// + public string LastActivity { get; set; } = string.Empty; + + /// + /// Error message when the agent status is "error". + /// + public string? ErrorMessage { get; set; } + + /// + /// Converts this DTO to an immutable record + /// for use with the typed SignalR client interface. + /// + /// An with equivalent field values. + public AgentStatusUpdate ToUpdate() => new( + AgentId, + DisplayName, + Role, + Status, + CurrentTask, + SessionKey, + Channel, + LastActivity, + ErrorMessage + ); +} + /// /// Snapshot of an agent's full card data, sent on initial connection /// or when the fleet state is requested. diff --git a/backend/ControlCenter/Models/AgentMinionMapping.cs b/backend/ControlCenter/Models/AgentMinionMapping.cs new file mode 100644 index 0000000..c9886dd --- /dev/null +++ b/backend/ControlCenter/Models/AgentMinionMapping.cs @@ -0,0 +1,72 @@ +namespace ControlCenter.Models; + +/// +/// Defines which side of the Control Center dashboard a minion occupies. +/// +public enum MinionSide +{ + /// Development side — Rex, Dex, Hex. + Dev, + + /// Business side — Larry, Mel, Buzz. + Business +} + +/// +/// Visual state of a minion sprite, derived from the agent's +/// . Maps Active/Idle/Thinking/Error +/// to frontend animation states. +/// +public enum MinionState +{ + /// Agent is actively processing — minion shows working animation. + Active, + + /// Agent is idle — minion shows idle/patrolling animation. + Idle, + + /// Agent is thinking (LLM call in flight) — minion shows thinking animation. + Thinking, + + /// Agent encountered an error — minion shows error/distress animation. + Error +} + +/// +/// Static mapping entry that associates an agent ID with a minion's +/// display side and position index within that side. +/// +/// Position indices are zero-based within each side. The dev side +/// has Rex at 0, Dex at 1, and Hex at 2. The business side has +/// Larry at 0, Mel at 1, and Buzz at 2. +/// +/// Agent identifier, e.g. "rex", "dex". +/// Which side of the dashboard the minion occupies. +/// Zero-based position index within the side. +/// Human-readable name, e.g. "Rex". +public record AgentMinionMapping( + string AgentId, + MinionSide Side, + int PositionIndex, + string DisplayName +); + +/// +/// Real-time minion state update pushed to SignalR clients +/// when an agent's status changes. Combines the static mapping +/// (who/where) with the dynamic state (what the minion is doing). +/// +/// Agent identifier, e.g. "rex". +/// Human-readable minion name, e.g. "Rex". +/// Which side of the dashboard — Dev or Business. +/// Position within the side (0-based). +/// Current minion animation state. +/// ISO 8601 timestamp of the state change. +public record MinionStateUpdate( + string AgentId, + string DisplayName, + MinionSide Side, + int PositionIndex, + MinionState State, + string Timestamp +); \ No newline at end of file diff --git a/backend/ControlCenter/Program.cs b/backend/ControlCenter/Program.cs index 757b20a..2b322ab 100644 --- a/backend/ControlCenter/Program.cs +++ b/backend/ControlCenter/Program.cs @@ -52,6 +52,11 @@ builder.Services.AddSignalR(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// ── Agent-Minion Mapper Service ──────────────────────────── +// Maps agents to minion sprites/positions and publishes state +// updates through SignalR. +builder.Services.AddSingleton(); + var app = builder.Build(); // ── Middleware ────────────────────────────────────────────── diff --git a/backend/ControlCenter/Services/AgentMinionMapperService.cs b/backend/ControlCenter/Services/AgentMinionMapperService.cs new file mode 100644 index 0000000..0dd82ef --- /dev/null +++ b/backend/ControlCenter/Services/AgentMinionMapperService.cs @@ -0,0 +1,193 @@ +using ControlCenter.Hubs; +using ControlCenter.Models; +using Microsoft.AspNetCore.SignalR; + +namespace ControlCenter.Services; + +/// +/// Service that maps Linear agents to minion sprites and positions +/// in the Control Center dashboard. +/// +/// Static mappings define where each minion appears: +/// +/// Dev side: Rex (0), Dex (1), Hex (2) +/// Business side: Larry (0), Mel (1), Buzz (2) +/// +/// +/// Dynamic state is derived from the agent's : +/// +/// Active +/// Idle +/// Thinking +/// Error +/// +/// +/// State updates are published through the +/// SignalR hub so that connected clients can animate minion sprites +/// in real time. +/// +public class AgentMinionMapperService +{ + private readonly ILogger _logger; + private readonly IHubContext _hubContext; + + /// + /// Static agent-to-minion mapping table. Defines which side and position + /// each agent's minion occupies on the dashboard. + /// + private static readonly Dictionary Mappings = new() + { + // ── Dev Side ────────────────────────────────── + ["rex"] = new AgentMinionMapping("rex", MinionSide.Dev, 0, "Rex"), + ["dex"] = new AgentMinionMapping("dex", MinionSide.Dev, 1, "Dex"), + ["hex"] = new AgentMinionMapping("hex", MinionSide.Dev, 2, "Hex"), + + // ── Business Side ───────────────────────────── + ["larry"] = new AgentMinionMapping("larry", MinionSide.Business, 0, "Larry"), + ["mel"] = new AgentMinionMapping("mel", MinionSide.Business, 1, "Mel"), + ["buzz"] = new AgentMinionMapping("buzz", MinionSide.Business, 2, "Buzz"), + }; + + /// + /// Maps string values to . + /// + private static readonly Dictionary StatusToMinionState = new() + { + ["active"] = MinionState.Active, + ["idle"] = MinionState.Idle, + ["thinking"] = MinionState.Thinking, + ["error"] = MinionState.Error, + }; + + public AgentMinionMapperService( + ILogger logger, + IHubContext hubContext) + { + _logger = logger; + _hubContext = hubContext; + } + + /// + /// Gets the minion mapping for a given agent ID. + /// Returns null if the agent is not mapped to a minion position. + /// + /// The agent identifier, e.g. "rex", "dex". + /// The mapping record, or null if unmapped. + public AgentMinionMapping? GetMapping(string agentId) + { + return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty); + } + + /// + /// Gets all minion mappings, ordered by side then position index. + /// + /// All mappings, sorted for consistent display order. + public IReadOnlyList GetAllMappings() + { + return Mappings.Values + .OrderBy(m => m.Side) + .ThenBy(m => m.PositionIndex) + .ToList(); + } + + /// + /// Converts an agent status string to a . + /// Falls back to for unrecognized statuses. + /// + /// Agent status string: "active", "idle", "thinking", or "error". + /// The corresponding minion state. + public MinionState StatusToState(string status) + { + return StatusToMinionState.GetValueOrDefault( + status?.ToLowerInvariant() ?? string.Empty, + MinionState.Idle); + } + + /// + /// Publishes a minion state update through SignalR when an agent's + /// status changes. Only publishes for agents that have a minion mapping. + /// + /// This is the primary integration point: the + /// calls this method + /// whenever it detects a status change from the OpenClaw Gateway. + /// + /// The agent whose status changed, e.g. "dex". + /// The new status string: "active", "idle", "thinking", or "error". + /// A task that completes when the SignalR message has been sent. + public async Task PublishMinionStateUpdateAsync(string agentId, string status) + { + var mapping = GetMapping(agentId); + if (mapping is null) + { + _logger.LogDebug("No minion mapping for agent {AgentId}; skipping state update", agentId); + return; + } + + var minionState = StatusToState(status); + var update = new MinionStateUpdate( + AgentId: mapping.AgentId, + DisplayName: mapping.DisplayName, + Side: mapping.Side, + PositionIndex: mapping.PositionIndex, + State: minionState, + Timestamp: DateTime.UtcNow.ToString("o") + ); + + // Broadcast to the fleet group (all subscribers) + await _hubContext.Clients.Group(AgentStatusHub.FleetGroupName) + .AgentStatusChanged(ToAgentStatusUpdate(agentId, status)); + + // Also push to the specific agent's group + var agentGroup = AgentStatusHub.AgentGroupName(agentId); + await _hubContext.Clients.Group(agentGroup) + .AgentStatusChanged(ToAgentStatusUpdate(agentId, status)); + + _logger.LogInformation( + "Minion state update: {AgentId} → {State} (Side: {Side}, Position: {Index})", + agentId, minionState, mapping.Side, mapping.PositionIndex); + } + + /// + /// Gets the current minion state for all mapped agents, suitable + /// for building an initial fleet snapshot. + /// + /// All minion mappings with their current (idle) state. + public IReadOnlyList GetFullMinionState() + { + return Mappings.Values + .OrderBy(m => m.Side) + .ThenBy(m => m.PositionIndex) + .Select(m => new MinionStateUpdate( + AgentId: m.AgentId, + DisplayName: m.DisplayName, + Side: m.Side, + PositionIndex: m.PositionIndex, + State: MinionState.Idle, + Timestamp: DateTime.UtcNow.ToString("o"))) + .ToList(); + } + + /// + /// Converts a status string to an + /// for SignalR push. Uses the mapping table for display names and roles. + /// + private AgentStatusUpdate ToAgentStatusUpdate(string agentId, string status) + { + var mapping = GetMapping(agentId); + var displayName = mapping?.DisplayName ?? char.ToUpperInvariant(agentId[0]) + agentId[1..]; + + return new AgentStatusUpdate( + AgentId: agentId, + DisplayName: displayName, + Role: mapping is not null + ? $"{mapping.Side} Agent" + : "Agent", + Status: status, + CurrentTask: null, + SessionKey: string.Empty, + Channel: string.Empty, + LastActivity: DateTime.UtcNow.ToString("o"), + ErrorMessage: status == "error" ? "Agent encountered an error" : null + ); + } +} \ No newline at end of file diff --git a/backend/Models/AgentState.cs b/backend/Models/AgentState.cs new file mode 100644 index 0000000..7c5f4a6 --- /dev/null +++ b/backend/Models/AgentState.cs @@ -0,0 +1,19 @@ +namespace ControlCenter.Api.Models; + +/// +/// Read-only model representing an agent's current state. +/// Used as the return type from the Agent State Repository +/// to decouple consumers from the persistence layer. +/// +public class AgentState +{ + public Guid Id { get; set; } + public string Status { get; set; } = string.Empty; + public string? Task { get; set; } + public int? Progress { get; set; } + public string SessionKey { get; set; } = string.Empty; + public string Channel { get; set; } = string.Empty; + public DateTime LastActivity { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index c45c355..d23abf3 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,5 +1,6 @@ using ControlCenter.Api.Data; using ControlCenter.Api.Hubs; +using ControlCenter.Api.Repositories; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -10,6 +11,9 @@ builder.Services.AddOpenApi(); // Register SignalR for real-time agent status updates builder.Services.AddSignalR(); +// Register Agent State Repository +builder.Services.AddScoped(); + // Register DbContext with PostgreSQL builder.Services.AddDbContext(options => { diff --git a/backend/Repositories/AgentStateRepository.cs b/backend/Repositories/AgentStateRepository.cs new file mode 100644 index 0000000..ca452ff --- /dev/null +++ b/backend/Repositories/AgentStateRepository.cs @@ -0,0 +1,76 @@ +using ControlCenter.Api.Data; +using ControlCenter.Api.Entities; +using ControlCenter.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace ControlCenter.Api.Repositories; + +/// +/// EF Core implementation of the Agent State Repository. +/// Maps between the persisted entity and the +/// read-oriented model. +/// +public class AgentStateRepository : IAgentStateRepository +{ + private readonly AppDbContext _db; + + public AgentStateRepository(AppDbContext db) + { + _db = db; + } + + /// + public async Task> GetAllAsync(CancellationToken ct = default) + { + var agents = await _db.Agents + .AsNoTracking() + .OrderByDescending(a => a.LastActivity) + .ToListAsync(ct); + + return agents.Select(ToModel).ToList(); + } + + /// + public async Task GetBySessionKeyAsync(string sessionKey, CancellationToken ct = default) + { + var agent = await _db.Agents + .AsNoTracking() + .FirstOrDefaultAsync(a => a.SessionKey == sessionKey, ct); + + return agent is null ? null : ToModel(agent); + } + + /// + public async Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default) + { + if (!Enum.TryParse(status, ignoreCase: true, out var parsedStatus)) + return false; + + var agent = await _db.Agents.FindAsync([id], ct); + if (agent is null) + return false; + + agent.Status = parsedStatus; + agent.UpdatedAt = DateTime.UtcNow; + agent.LastActivity = DateTime.UtcNow; + + await _db.SaveChangesAsync(ct); + return true; + } + + /// + /// Maps a persisted entity to a model. + /// + private static AgentState ToModel(Agent agent) => new() + { + Id = agent.Id, + Status = agent.Status.ToString(), + Task = agent.Task, + Progress = agent.Progress, + SessionKey = agent.SessionKey, + Channel = agent.Channel, + LastActivity = agent.LastActivity, + CreatedAt = agent.CreatedAt, + UpdatedAt = agent.UpdatedAt, + }; +} \ No newline at end of file diff --git a/backend/Repositories/IAgentStateRepository.cs b/backend/Repositories/IAgentStateRepository.cs new file mode 100644 index 0000000..4c0c2b9 --- /dev/null +++ b/backend/Repositories/IAgentStateRepository.cs @@ -0,0 +1,27 @@ +using ControlCenter.Api.Models; + +namespace ControlCenter.Api.Repositories; + +/// +/// Repository interface for accessing and mutating Agent State. +/// Provides a clean abstraction over the EF Core data access layer. +/// +public interface IAgentStateRepository +{ + /// + /// Retrieve all agent states. + /// + Task> GetAllAsync(CancellationToken ct = default); + + /// + /// Retrieve a single agent state by its session key. + /// Returns null if no agent is found with the given session key. + /// + Task GetBySessionKeyAsync(string sessionKey, CancellationToken ct = default); + + /// + /// Update the status of an agent by its primary key. + /// Returns true if the agent was found and updated, false otherwise. + /// + Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default); +} \ No newline at end of file diff --git a/frontend/src/app/command-hub/components/agent-card/agent-card.component.html b/frontend/src/app/command-hub/components/agent-card/agent-card.component.html new file mode 100644 index 0000000..8140132 --- /dev/null +++ b/frontend/src/app/command-hub/components/agent-card/agent-card.component.html @@ -0,0 +1,82 @@ + + + + + +
+ + +
+
+ + {{ statusLabel() }} +
+ +
+ {{ displayName || agentId }} + {{ role }} +
+
+ + +
+

+ {{ status === 'error' ? errorMessage || task : task }} +

+
+ + +
+ + {{ progress }}% +
+ + + +
\ No newline at end of file diff --git a/frontend/src/app/command-hub/components/agent-card/agent-card.component.scss b/frontend/src/app/command-hub/components/agent-card/agent-card.component.scss new file mode 100644 index 0000000..2846e74 --- /dev/null +++ b/frontend/src/app/command-hub/components/agent-card/agent-card.component.scss @@ -0,0 +1,234 @@ +// ============================================================================ +// AgentCard — M3 tactical dark styling +// Per spec Section 7.3: left‑border accent, status‑aware coloring, +// responsive card layout with 320px min‑width. +// ============================================================================ + +.agent-card { + display: flex; + flex-direction: column; + min-width: var(--cc-card-min-width); + padding: var(--cc-card-padding); + background-color: var(--cc-surface-container); + border-radius: var(--cc-card-border-radius); + border-left: 4px solid var(--status-offline); // default; overridden by [style] + border-top: 1px solid var(--cc-outline); + border-right: 1px solid var(--cc-outline); + border-bottom: 1px solid var(--cc-outline); + gap: 16px; + transition: border-left-color 0.3s ease, box-shadow 0.2s ease; + cursor: default; + + &:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + &:focus-within { + outline: 2px solid var(--status-active); + outline-offset: 2px; + } +} + +// ── Header ── +.agent-card__header { + display: flex; + align-items: center; + gap: 12px; +} + +.agent-card__badge { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + background-color: var(--status-active-bg); // overridden per status below + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--cc-on-surface); + + // Per‑status background tints + .status-dot--active + & { + background-color: var(--status-active-bg); + } +} + +.agent-card__status-label { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--cc-on-surface-variant); +} + +.agent-card__identity { + display: flex; + flex-direction: column; + gap: 2px; +} + +.agent-card__name { + font-size: 16px; + font-weight: 600; + color: var(--cc-on-surface); + line-height: 1.2; +} + +.agent-card__role { + font-size: 12px; + font-weight: 400; + color: var(--cc-on-surface-variant); +} + +// ── Body ── +.agent-card__body { + padding: 4px 0; +} + +.agent-card__task { + margin: 0; + font-size: 14px; + font-weight: 400; + color: var(--cc-on-surface); + line-height: 1.4; + + // Error messages get distinct styling + .agent-card--error & { + color: var(--status-error); + } +} + +// ── Progress Bar ── +.agent-card__progress { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} + +.agent-card__progress-label { + font-size: 12px; + font-weight: 500; + color: var(--cc-on-surface-variant); + white-space: nowrap; + min-width: 36px; +} + +// Override mat-progress-bar to match tactical dark theme +.agent-card__progress ::ng-deep .mat-mdc-progress-bar { + height: 4px; + border-radius: 2px; + + .mdc-linear-progress__bar-inner { + background-color: var(--status-active); + } + + .mdc-linear-progress__track { + background-color: var(--cc-outline); + } +} + +// ── Footer ── +.agent-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: auto; // push footer to bottom +} + +.agent-card__meta { + display: flex; + align-items: center; + gap: 12px; +} + +.agent-card__channel { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--cc-on-surface-variant); +} + +.agent-card__channel-icon, +.agent-card__channel .mat-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + +.agent-card__last-activity { + font-size: 12px; + color: var(--cc-on-surface-variant); +} + +// ── Quick‑Jump Button ── +.agent-card__jump { + flex-shrink: 0; + + // Match M3 text button sizing + .mat-mdc-button { + min-width: 36px; + padding: 0 8px; + color: var(--status-active); + } + + .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +// ── Status‑specific background tints for badge ── +// We use the global status-dot classes from styles.scss and pair them +// with contextual background-color overrides here. + +.agent-card[data-status="active"] .agent-card__badge, +.agent-card .status-dot--active ~ .agent-card__badge { + background-color: var(--status-active-bg); +} + +.agent-card[data-status="idle"] .agent-card__badge { + background-color: var(--status-idle-bg); +} + +.agent-card[data-status="thinking"] .agent-card__badge { + background-color: var(--status-thinking-bg); +} + +.agent-card[data-status="error"] .agent-card__badge { + background-color: var(--status-error-bg); +} + +// ── Responsive ── +@media (max-width: 599px) { + .agent-card { + min-width: unset; + padding: 16px; + } + + .agent-card__header { + flex-wrap: wrap; + gap: 8px; + } + + .agent-card__footer { + flex-wrap: wrap; + gap: 8px; + } + + .agent-card__meta { + gap: 8px; + } +} + +// ── Accessibility: reduced motion ── +@media (prefers-reduced-motion: reduce) { + .agent-card { + transition: none; + } +} \ No newline at end of file diff --git a/frontend/src/app/command-hub/components/agent-card/agent-card.component.ts b/frontend/src/app/command-hub/components/agent-card/agent-card.component.ts new file mode 100644 index 0000000..a378e62 --- /dev/null +++ b/frontend/src/app/command-hub/components/agent-card/agent-card.component.ts @@ -0,0 +1,127 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + computed, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { AgentStatus } from '../../../models/agent.model'; + +// ============================================================================ +// AgentCard Component +// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar, +// and Quick‑Jump Button into a card with left‑border status accent. +// ============================================================================ + +@Component({ + selector: 'app-agent-card', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatIconModule, + MatButtonModule, + MatProgressBarModule, + MatTooltipModule, + ], + templateUrl: './agent-card.component.html', + styleUrl: './agent-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AgentCardComponent { + // --- Six required inputs per spec --- + + /** Agent status — drives badge color and left‑border accent */ + @Input({ required: true }) status!: AgentStatus; + + /** Current task description, e.g. "Reviewing PR #42" */ + @Input() task = ''; + + /** Task progress percentage 0–100 */ + @Input() progress = 0; + + /** Full session key for quick‑jump navigation */ + @Input({ required: true }) sessionKey = ''; + + /** Communication channel, e.g. "telegram" */ + @Input({ required: true }) channel = ''; + + /** Timestamp of last agent activity */ + @Input({ required: true }) lastActivity!: Date; + + // --- Additional display inputs --- + + /** Short agent ID, e.g. "otto" */ + @Input() agentId = ''; + + /** Display name, e.g. "Otto" */ + @Input() displayName = ''; + + /** Role description, e.g. "Orchestrator Agent" */ + @Input() role = ''; + + /** Error message (shown only when status is 'error') */ + @Input() errorMessage = ''; + + // --- Computed values --- + + /** Map status → CSS custom property for the left‑border accent */ + readonly statusBorderColor = computed(() => { + const map: Record = { + active: 'var(--status-active)', + idle: 'var(--status-idle)', + thinking: 'var(--status-thinking)', + error: 'var(--status-error)', + offline: 'var(--status-offline)', + }; + return map[this.status] ?? 'var(--status-offline)'; + }); + + /** Human‑readable status label */ + readonly statusLabel = computed(() => { + const labels: Record = { + active: 'Active', + idle: 'Idle', + thinking: 'Thinking…', + error: 'Error', + offline: 'Offline', + }; + return labels[this.status] ?? this.status; + }); + + /** CSS class suffix for the status badge dot */ + readonly statusDotClass = computed(() => `status-dot--${this.status}`); + + /** Material icon name for the channel */ + readonly channelIcon = computed(() => { + const icons: Record = { + telegram: 'telegram', // falls back to font icon if no SVG registered + slack: 'chat', + discord: 'forum', + whatsapp: 'chat', + webchat: 'language', + email: 'email', + }; + return icons[this.channel] ?? 'chat'; + }); + + /** Relative time string for lastActivity */ + readonly lastActivityLabel = computed(() => { + if (!this.lastActivity) return ''; + const now = Date.now(); + const then = this.lastActivity.getTime(); + const diffSec = Math.max(0, Math.floor((now - then) / 1000)); + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return `${Math.floor(diffSec / 86400)}d ago`; + }); + + /** Quick‑jump route derived from sessionKey */ + readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`); +} \ No newline at end of file diff --git a/frontend/src/app/command-hub/components/index.ts b/frontend/src/app/command-hub/components/index.ts new file mode 100644 index 0000000..39f0d45 --- /dev/null +++ b/frontend/src/app/command-hub/components/index.ts @@ -0,0 +1 @@ +export * from './agent-card/agent-card.component'; \ No newline at end of file diff --git a/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.html b/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.html new file mode 100644 index 0000000..c51fffa --- /dev/null +++ b/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.html @@ -0,0 +1,112 @@ + + + + + + + + + + + + + +@if (mobileMenuOpen()) { + + +} \ No newline at end of file diff --git a/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.scss b/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.scss new file mode 100644 index 0000000..27fbd5b --- /dev/null +++ b/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.scss @@ -0,0 +1,316 @@ +// ============================================================================ +// Adaptive Navigation — Desktop sidebar / Mobile header +// Desktop (≥768px): 72px sidebar with full navigation items +// Mobile (<768px): 56px compact header with hamburger menu +// ============================================================================ + +// --------------------------------------------------------------------------- +// Desktop Sidebar (visible ≥768px) +// --------------------------------------------------------------------------- +.adaptive-nav__sidebar { + display: flex; + flex-direction: column; + width: var(--cc-nav-rail-collapsed-width, 72px); + min-height: 100vh; + background-color: var(--cc-surface-container-high); + border-right: 1px solid var(--cc-outline); + z-index: 10; +} + +.adaptive-nav__sidebar-header { + display: flex; + align-items: center; + justify-content: center; + height: 64px; + border-bottom: 1px solid var(--cc-outline); +} + +.adaptive-nav__brand { + font-size: 18px; + font-weight: 700; + color: var(--status-active); + letter-spacing: 0.04em; +} + +.adaptive-nav__sidebar-nav { + flex: 1; + padding-top: 8px; +} + +.adaptive-nav__sidebar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 56px; + padding: 8px 0; + margin: 2px 8px; + border-radius: 28px; + color: var(--cc-on-surface-variant); + text-decoration: none; + transition: background-color 150ms ease, color 150ms ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.08); + color: var(--cc-on-surface); + } + + &--active { + background-color: var(--status-active-bg); + color: var(--status-active); + + .adaptive-nav__sidebar-label { + font-weight: 500; + } + } +} + +.adaptive-nav__sidebar-label { + font-size: 11px; + font-weight: 400; + letter-spacing: 0.02em; + white-space: nowrap; +} + +// --------------------------------------------------------------------------- +// Sidebar Footer — LIVE indicator + action buttons +// --------------------------------------------------------------------------- +.adaptive-nav__sidebar-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 0 20px; + border-top: 1px solid var(--cc-outline); +} + +.adaptive-nav__sidebar-actions { + display: flex; + gap: 4px; + + .mat-mdc-icon-button { + color: var(--cc-on-surface-variant) !important; + --mdc-icon-button-icon-size: 20px; + + &:hover { + color: var(--cc-on-surface) !important; + } + } +} + +// --------------------------------------------------------------------------- +// LIVE Status Indicator +// --------------------------------------------------------------------------- +.adaptive-nav__live { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 16px; + transition: background-color 200ms ease; + + &--connected { + background-color: var(--status-active-bg); + } +} + +.adaptive-nav__live-dot { + display: inline-block; + width: 8px; + height: 8px; + min-width: 8px; + border-radius: 50%; + background-color: var(--status-error); + transition: background-color 200ms ease; + + &--connected { + background-color: var(--status-active); + animation: pulse-active 2s ease-in-out infinite; + } +} + +.adaptive-nav__live-chip { + font-size: 11px !important; + font-weight: 600 !important; + letter-spacing: 0.06em; + height: 24px !important; + min-height: 24px !important; + padding: 0 8px !important; + color: var(--status-active) !important; + --mdc-chip-elevated-container-color: transparent; + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +.adaptive-nav__live-text { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--status-active); +} + +// --------------------------------------------------------------------------- +// Mobile Header (visible <768px) +// --------------------------------------------------------------------------- +.adaptive-nav__mobile-header { + display: none; // Hidden on desktop, shown on mobile via media query + align-items: center; + height: 56px; + padding: 0 12px; + background-color: var(--cc-surface-container-high); + border-bottom: 1px solid var(--cc-outline); + z-index: 20; + gap: 8px; +} + +.adaptive-nav__hamburger { + color: var(--cc-on-surface-variant) !important; + + &:hover { + color: var(--cc-on-surface) !important; + } +} + +.adaptive-nav__mobile-title { + flex: 1; + font-size: 20px; + font-weight: 500; + color: var(--cc-on-surface); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.adaptive-nav__live--mobile { + padding: 4px 10px; + border-radius: 16px; + + .adaptive-nav__live-text { + font-size: 11px; + font-weight: 700; + } +} + +.adaptive-nav__mobile-action { + color: var(--cc-on-surface-variant) !important; + + &:hover { + color: var(--cc-on-surface) !important; + } +} + +// --------------------------------------------------------------------------- +// Mobile Drawer +// --------------------------------------------------------------------------- +.adaptive-nav__overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 40; +} + +.adaptive-nav__mobile-drawer { + position: fixed; + top: 56px; // Below header + left: 0; + bottom: 0; + width: 280px; + max-width: 80vw; + background-color: var(--cc-surface-container); + border-right: 1px solid var(--cc-outline); + z-index: 50; + padding: 12px 0; + overflow-y: auto; + animation: slide-in-left 200ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.adaptive-nav__drawer-item { + display: flex; + align-items: center; + gap: 16px; + min-height: 48px; + padding: 0 20px; + color: var(--cc-on-surface-variant); + text-decoration: none; + transition: background-color 150ms ease, color 150ms ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.08); + color: var(--cc-on-surface); + } + + &--active { + background-color: var(--status-active-bg); + color: var(--status-active); + + .adaptive-nav__drawer-label { + font-weight: 500; + } + } +} + +.adaptive-nav__drawer-label { + font-size: 14px; + font-weight: 400; + white-space: nowrap; +} + +// --------------------------------------------------------------------------- +// Drawer slide-in animation +// --------------------------------------------------------------------------- +@keyframes slide-in-left { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +// --------------------------------------------------------------------------- +// Media Queries — Layout Switch +// --------------------------------------------------------------------------- +// Desktop (≥768px): Show sidebar, hide mobile header +// Mobile (<768px): Hide sidebar, show compact header +// --------------------------------------------------------------------------- +@media (min-width: 768px) { + .adaptive-nav__sidebar { + display: flex; + } + + .adaptive-nav__mobile-header { + display: none; + } + + // Hide mobile drawer and overlay on desktop + .adaptive-nav__overlay, + .adaptive-nav__mobile-drawer { + display: none; + } +} + +@media (max-width: 767px) { + .adaptive-nav__sidebar { + display: none; + } + + .adaptive-nav__mobile-header { + display: flex; + } +} + +// --------------------------------------------------------------------------- +// Accessibility: Reduced Motion +// --------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .adaptive-nav__live-dot--connected { + animation: none; + } + + .adaptive-nav__mobile-drawer { + animation: none; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.ts b/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.ts new file mode 100644 index 0000000..77adc96 --- /dev/null +++ b/frontend/src/app/components/adaptive-navigation/adaptive-navigation.component.ts @@ -0,0 +1,53 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatBadgeModule } from '@angular/material/badge'; +import { NAV_DESTINATIONS } from '../../models/nav.model'; + +/** + * Adaptive Navigation Component — switches between desktop sidebar + * and mobile header layouts using CSS media queries. + * + * Desktop (≥768px): 72px sidebar with full navigation items. + * Mobile (<768px): 56px compact header with hamburger menu. + * + * The LIVE status indicator is visible in both layouts. + * Per spec Section 3.1 (kiosk) and 3.2 (mobile). + */ +@Component({ + selector: 'app-adaptive-navigation', + standalone: true, + imports: [ + RouterLink, + RouterLinkActive, + MatIconModule, + MatButtonModule, + MatChipsModule, + MatBadgeModule, + ], + templateUrl: './adaptive-navigation.component.html', + styleUrl: './adaptive-navigation.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdaptiveNavigationComponent { + /** Navigation destinations shared with other nav components */ + protected readonly destinations = NAV_DESTINATIONS; + + /** Whether the mobile drawer is open */ + protected readonly mobileMenuOpen = signal(false); + + /** Live connection status */ + protected readonly isConnected = signal(true); + + /** Toggle mobile menu */ + toggleMobileMenu(): void { + this.mobileMenuOpen.update((v) => !v); + } + + /** Close mobile menu (e.g. on nav) */ + closeMobileMenu(): void { + this.mobileMenuOpen.set(false); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/adaptive-navigation/index.ts b/frontend/src/app/components/adaptive-navigation/index.ts new file mode 100644 index 0000000..c154a5e --- /dev/null +++ b/frontend/src/app/components/adaptive-navigation/index.ts @@ -0,0 +1 @@ +export * from './adaptive-navigation.component'; \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/agent-status-badge.component.html b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.html new file mode 100644 index 0000000..e88ea86 --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.html @@ -0,0 +1,8 @@ + + + {{ displayLabel }} + \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/agent-status-badge.component.scss b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.scss new file mode 100644 index 0000000..331d6f9 --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.scss @@ -0,0 +1,146 @@ +// ============================================================================ +// Agent Status Badge — per spec Section 7.3 +// Colored pill with dot indicator and optional pulse animation. +// ============================================================================ + +$badge-height: 24px; +$dot-size: 8px; +$border-radius: 12px; +$font-size: 12px; +$font-weight: 500; +$padding-x: 8px; +$gap: 6px; + +@use 'sass:color'; + +// Status color palette +$color-active: #22c55e; // green-500 +$color-idle: #9ca3af; // gray-400 +$color-thinking: #3b82f6; // blue-500 +$color-error: #ef4444; // red-500 +$color-offline: #9ca3af; // gray-400 + +// Background tints (12% opacity for soft pill background) +$bg-active: rgba($color-active, 0.12); +$bg-idle: rgba($color-idle, 0.12); +$bg-thinking: rgba($color-thinking, 0.12); +$bg-error: rgba($color-error, 0.12); +$bg-offline: rgba($color-offline, 0.12); + +// --------------------------------------------------------------------------- +// Base pill +// --------------------------------------------------------------------------- +.badge { + display: inline-flex; + align-items: center; + height: $badge-height; + padding: 0 $padding-x; + border-radius: $border-radius; + gap: $gap; + font-size: $font-size; + font-weight: $font-weight; + line-height: 1; + white-space: nowrap; + user-select: none; +} + +// --------------------------------------------------------------------------- +// Dot indicator +// --------------------------------------------------------------------------- +.badge__dot { + width: $dot-size; + height: $dot-size; + border-radius: 50%; + flex-shrink: 0; +} + +// --------------------------------------------------------------------------- +// Label text +// --------------------------------------------------------------------------- +.badge__label { + line-height: 1; +} + +// --------------------------------------------------------------------------- +// Status color variants +// --------------------------------------------------------------------------- +.badge--active { + background: $bg-active; + color: color.adjust($color-active, $lightness: -10%); + + .badge__dot { + background: $color-active; + } +} + +.badge--idle { + background: $bg-idle; + color: color.adjust($color-idle, $lightness: -15%); + + .badge__dot { + background: $color-idle; + } +} + +.badge--thinking { + background: $bg-thinking; + color: color.adjust($color-thinking, $lightness: -10%); + + .badge__dot { + background: $color-thinking; + } +} + +.badge--error { + background: $bg-error; + color: color.adjust($color-error, $lightness: -10%); + + .badge__dot { + background: $color-error; + } +} + +.badge--offline { + background: $bg-offline; + color: color.adjust($color-offline, $lightness: -15%); + + .badge__dot { + background: $color-offline; + } +} + +// --------------------------------------------------------------------------- +// Pulse animation — applied when status is active, thinking, or error +// --------------------------------------------------------------------------- +.badge--pulse { + .badge__dot { + animation: pulse-dot 2s ease-in-out infinite; + } +} + +// Active: 2s pulse +.badge--active.badge--pulse .badge__dot { + animation-duration: 2s; +} + +// Thinking: 3s pulse +.badge--thinking.badge--pulse .badge__dot { + animation-duration: 3s; +} + +// Error: 0.8s pulse (fast, urgent) +.badge--error.badge--pulse .badge__dot { + animation-duration: 0.8s; +} + +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(1.5); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/agent-status-badge.component.ts b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.ts new file mode 100644 index 0000000..a246d63 --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { AgentStatus } from '../../models/agent.model'; + +/** + * Agent Status Badge component. + * Displays a colored pill with a pulse animation indicating the agent's current status. + * Per spec Section 7.3: Agent Card Component Interface — status indicator. + * + * Color mapping: + * - Active → green + * - Idle → gray + * - Thinking → blue + * - Error → red + * - Offline → gray (no pulse) + * + * Pulse animations: + * - Active → 2s + * - Error → 0.8s + * - Thinking → 3s + * - Idle / Offline → no pulse + */ +@Component({ + selector: 'app-agent-status-badge', + standalone: true, + imports: [], + templateUrl: './agent-status-badge.component.html', + styleUrl: './agent-status-badge.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AgentStatusBadgeComponent { + /** Current agent status — binds to the AgentStatus type from the model. */ + readonly status = input.required(); + + /** Label text shown inside the badge. Defaults to title-cased status. */ + readonly label = input(); + + get displayLabel(): string { + return this.label() ?? this.titleCase(this.status()); + } + + /** CSS class driven by the current status value. */ + get statusClass(): string { + return `badge--${this.status()}`; + } + + /** Whether the pulse animation should be active for the current status. */ + get hasPulse(): boolean { + return this.status() === 'active' || this.status() === 'thinking' || this.status() === 'error'; + } + + private titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/index.ts b/frontend/src/app/components/agent-status-badge/index.ts new file mode 100644 index 0000000..e531d31 --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/index.ts @@ -0,0 +1 @@ +export { AgentStatusBadgeComponent } from './agent-status-badge.component'; \ No newline at end of file diff --git a/frontend/src/app/components/global-action-modal/global-action-modal.component.html b/frontend/src/app/components/global-action-modal/global-action-modal.component.html new file mode 100644 index 0000000..47eb934 --- /dev/null +++ b/frontend/src/app/components/global-action-modal/global-action-modal.component.html @@ -0,0 +1,31 @@ + +
+ + + \ No newline at end of file diff --git a/frontend/src/app/components/global-action-modal/global-action-modal.component.scss b/frontend/src/app/components/global-action-modal/global-action-modal.component.scss new file mode 100644 index 0000000..fce6731 --- /dev/null +++ b/frontend/src/app/components/global-action-modal/global-action-modal.component.scss @@ -0,0 +1,198 @@ +// ============================================================================ +// Global Action Modal — Tactical Dark Mode Styling +// Uses Control Center design tokens from styles.scss +// ============================================================================ + +// --------------------------------------------------------------------------- +// Backdrop +// --------------------------------------------------------------------------- +:host { + display: block; + position: fixed; + inset: 0; + z-index: 1000; +} + +.global-action-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +// --------------------------------------------------------------------------- +// Modal Panel +// --------------------------------------------------------------------------- +.global-action-modal__panel { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(560px, calc(100vw - 48px)); + background: var(--cc-surface-container); + border: 1px solid var(--cc-outline); + border-radius: var(--cc-card-border-radius); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- +.global-action-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 12px; +} + +.global-action-modal__title { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--cc-on-surface); + letter-spacing: 0.01em; +} + +.global-action-modal__close { + --mat-icon-button-state-layer-color: transparent; + color: var(--cc-on-surface-variant); + + &:hover { + color: var(--cc-on-surface); + } +} + +// --------------------------------------------------------------------------- +// Action Grid +// --------------------------------------------------------------------------- +.global-action-modal__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 12px 24px 24px; +} + +// --------------------------------------------------------------------------- +// Action Button +// --------------------------------------------------------------------------- +.global-action-modal__action-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px 16px; + border: 1px solid var(--cc-outline); + border-radius: 12px; + background: var(--cc-surface); + color: var(--cc-on-surface); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, transform 100ms ease; + font-family: inherit; + text-align: center; + + &:hover { + background: var(--cc-surface-container-high); + border-color: var(--cc-on-surface-variant); + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } + + &:focus-visible { + outline: 2px solid var(--mat-sys-primary, #38BDF8); + outline-offset: 2px; + } +} + +// Action icon wrapper +.global-action-modal__action-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + + .mat-icon { + width: 28px; + height: 28px; + font-size: 28px; + } +} + +// Action label +.global-action-modal__action-label { + font-size: 15px; + font-weight: 600; + letter-spacing: 0.01em; +} + +// Action description +.global-action-modal__action-desc { + font-size: 12px; + color: var(--cc-on-surface-variant); + line-height: 1.4; +} + +// --------------------------------------------------------------------------- +// Color Variants — per-action accent colors +// --------------------------------------------------------------------------- +.global-action-modal__action-btn--deploy { + .global-action-modal__action-icon { + background: var(--status-active-bg); + color: var(--status-active); + } + + &:hover { + border-color: var(--status-active); + } +} + +.global-action-modal__action-btn--pause { + .global-action-modal__action-icon { + background: var(--status-idle-bg); + color: var(--status-idle); + } + + &:hover { + border-color: var(--status-idle); + } +} + +.global-action-modal__action-btn--emergency { + .global-action-modal__action-icon { + background: var(--status-error-bg); + color: var(--status-error); + } + + &:hover { + border-color: var(--status-error); + } + + .global-action-modal__action-label { + color: var(--status-error); + } +} + +.global-action-modal__action-btn--add { + .global-action-modal__action-icon { + background: var(--status-thinking-bg); + color: var(--status-thinking); + } + + &:hover { + border-color: var(--status-thinking); + } +} + +// --------------------------------------------------------------------------- +// Responsive — stack single column on narrow viewports +// --------------------------------------------------------------------------- +@media (max-width: 400px) { + .global-action-modal__actions { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/global-action-modal/global-action-modal.component.ts b/frontend/src/app/components/global-action-modal/global-action-modal.component.ts new file mode 100644 index 0000000..dc36fa2 --- /dev/null +++ b/frontend/src/app/components/global-action-modal/global-action-modal.component.ts @@ -0,0 +1,87 @@ +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; + +/** + * Global Action Modal — overlay for fleet-wide commands. + * + * Four main actions: Deploy All, Pause All, Emergency Stop, Add Agent. + * Tactical Dark Mode styling using Control Center design tokens. + * Dismisses on backdrop click or close button. + */ +@Component({ + selector: 'app-global-action-modal', + standalone: true, + imports: [MatIconModule, MatButtonModule], + templateUrl: './global-action-modal.component.html', + styleUrl: './global-action-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GlobalActionModalComponent { + /** Emitted when any action button is clicked. Payload is the action key. */ + @Output() readonly actionSelected = new EventEmitter(); + + /** Emitted when the modal is dismissed (backdrop click or close button). */ + @Output() readonly dismissed = new EventEmitter(); + + @ViewChild('backdrop') backdropEl!: ElementRef; + + /** All available global actions. */ + readonly actions: GlobalActionDef[] = [ + { + key: 'deploy-all', + label: 'Deploy All', + description: 'Deploy all agents in the fleet', + icon: 'rocket_launch', + color: 'deploy', + }, + { + key: 'pause-all', + label: 'Pause All', + description: 'Pause all running agents', + icon: 'pause_circle', + color: 'pause', + }, + { + key: 'emergency-stop', + label: 'Emergency Stop', + description: 'Immediately halt all agents', + icon: 'emergency', + color: 'emergency', + }, + { + key: 'add-agent', + label: 'Add Agent', + description: 'Register a new agent to the fleet', + icon: 'person_add', + color: 'add', + }, + ]; + + onBackdropClick(): void { + this.dismissed.emit(); + } + + onModalClick(event: Event): void { + // Prevent clicks inside the modal panel from closing it + event.stopPropagation(); + } + + onClose(): void { + this.dismissed.emit(); + } + + onAction(action: GlobalActionDef): void { + this.actionSelected.emit(action.key); + } +} + +export type GlobalAction = 'deploy-all' | 'pause-all' | 'emergency-stop' | 'add-agent'; + +export interface GlobalActionDef { + key: GlobalAction; + label: string; + description: string; + icon: string; + color: 'deploy' | 'pause' | 'emergency' | 'add'; +} \ No newline at end of file diff --git a/frontend/src/app/components/index.ts b/frontend/src/app/components/index.ts index bf5c601..730b615 100644 --- a/frontend/src/app/components/index.ts +++ b/frontend/src/app/components/index.ts @@ -1 +1,3 @@ +export * from './quick-jump-button/quick-jump-button.component'; +export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component'; export { QuickJumpDrawerComponent } from './quick-jump-drawer/index'; \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-button/quick-jump-button.component.html b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.html new file mode 100644 index 0000000..3aa3c3c --- /dev/null +++ b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-button/quick-jump-button.component.scss b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.scss new file mode 100644 index 0000000..718b441 --- /dev/null +++ b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.scss @@ -0,0 +1,68 @@ +// ============================================================================ +// Quick-Jump Button — M3 FilledTonalIconButton +// Per spec Section 7.3: Agent Card Quick-Jump action +// M3 spec: FilledTonalIconButton uses secondary container color +// with 8% state layer overlay for hover/focus. +// ============================================================================ + +.quick-jump-button { + // M3 FilledTonalIconButton: secondary-container background + // Angular Material mat-icon-button sets up the base shape (40x40, round). + // We override the color tokens to match FilledTonal style. + --mdc-icon-button-icon-color: var(--mat-sys-on-secondary-container); + background-color: var(--mat-sys-secondary-container); + border-radius: 50%; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); + + // M3 State Layer: 8% overlay on hover + &:hover { + background-color: var(--mat-sys-secondary-container); + // State layer overlay using a pseudo-element for precise 8% opacity + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--mat-sys-on-secondary-container); + opacity: 0.08; + pointer-events: none; + } + } + + // M3 State Layer: 12% overlay on focus-visible (slightly stronger for accessibility) + &:focus-visible { + background-color: var(--mat-sys-secondary-container); + outline: 3px solid var(--status-active); + outline-offset: 2px; + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--mat-sys-on-secondary-container); + opacity: 0.12; + pointer-events: none; + } + } + + // M3 State Layer: 12% overlay on active/pressed + &:active { + background-color: var(--mat-sys-secondary-container); + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--mat-sys-on-secondary-container); + opacity: 0.12; + pointer-events: none; + } + } + + // Icon color stays on-secondary-container across all states + .mat-icon { + color: var(--mat-sys-on-secondary-container); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-button/quick-jump-button.component.ts b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.ts new file mode 100644 index 0000000..899fc1d --- /dev/null +++ b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.ts @@ -0,0 +1,32 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; + +/** + * Quick-Jump Button — M3 FilledTonalIconButton + * + * An icon button that emits a navigation event for jumping to an agent session. + * Uses the Material Design 3 FilledTonalIconButton style with 8% state layer + * overlay on hover and focus. + * + * Per spec Section 7.3: Agent Card Component Interface + */ +@Component({ + selector: 'app-quick-jump-button', + standalone: true, + imports: [MatIconButton, MatIcon], + templateUrl: './quick-jump-button.component.html', + styleUrl: './quick-jump-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuickJumpButtonComponent { + /** Emitted when the button is clicked, carrying the session key for navigation. */ + @Output() jumpClick = new EventEmitter(); + + /** The session key to navigate to. Set by the parent agent card. */ + sessionKey = ''; + + onJumpClick(): void { + this.jumpClick.emit(this.sessionKey); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/index.ts b/frontend/src/app/components/task-progress-bar/index.ts new file mode 100644 index 0000000..67414db --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/index.ts @@ -0,0 +1,6 @@ +// ============================================================================ +// Task Progress Bar — Barrel Export +// CUB-44 +// ============================================================================ + +export { TaskProgressBarComponent } from './task-progress-bar.component'; \ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/task-progress-bar.component.html b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.html new file mode 100644 index 0000000..f8d8a7d --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.html @@ -0,0 +1,18 @@ + +
+ +
+ {{ clampedProgress }}% + + {{ elapsedText }} + +
+ + + +
\ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/task-progress-bar.component.scss b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.scss new file mode 100644 index 0000000..bb467a2 --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.scss @@ -0,0 +1,77 @@ +// ============================================================================ +// Task Progress Bar — Tactical Dark Theme Styling +// Per CUB-44: Uses --color-primary for bar fill and --color-surface-light +// for track background, mapped to the Control Center's M3 dark tokens. +// ============================================================================ + +// --------------------------------------------------------------------------- +// Container +// --------------------------------------------------------------------------- +.task-progress-bar { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +// --------------------------------------------------------------------------- +// Info row: percentage label + elapsed time +// --------------------------------------------------------------------------- +.task-progress-bar__info { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.task-progress-bar__percent { + font-family: var(--cc-font-mono, 'Roboto Mono', monospace); + font-size: 14px; + font-weight: 600; + color: var(--cc-on-surface, #E2E8F0); + letter-spacing: 0.02em; +} + +.task-progress-bar__elapsed { + font-family: var(--cc-font-mono, 'Roboto Mono', monospace); + font-size: 12px; + font-weight: 400; + color: var(--cc-on-surface-variant, #8A9BB0); + letter-spacing: 0.01em; +} + +// --------------------------------------------------------------------------- +// Material Progress Bar Overrides +// --------------------------------------------------------------------------- +// Map the spec's --color-primary and --color-surface-light to the Control +// Center's actual theme tokens. This ensures the bar uses the tactical dark +// palette while respecting the spec's variable naming. +// --------------------------------------------------------------------------- + +.task-progress-bar__bar { + // Override the track (background) to use the surface container + --mat-progress-bar-track-height: 6px; + --mat-progress-bar-active-indicator-height: 6px; + + // Bar fill color: primary (cyan/sky blue per tactical dark theme) + --mat-progress-bar-active-indicator-color: var(--color-primary, var(--mat-sys-primary, #38BDF8)); + + // Track background: surface container (dark slate) + --mat-progress-bar-track-color: var(--color-surface-light, var(--cc-surface-container, #1C2027)); + + // Border radius for a softer bar + border-radius: 3px; + + // Smooth transition on value changes + transition: none; +} + +// Rounded ends on the progress bar fill +:host ::ng-deep .mdc-linear-progress__bar-inner { + border-radius: 3px; +} + +// Rounded track background +:host ::ng-deep .mdc-linear-progress__track { + border-radius: 3px; +} \ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/task-progress-bar.component.ts b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.ts new file mode 100644 index 0000000..a380566 --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.ts @@ -0,0 +1,109 @@ +// ============================================================================ +// Task Progress Bar Component +// Per CUB-44: Determinate progress bar with optional elapsed time display. +// Uses Angular Material mat-progress-bar in determinate mode with tactical +// dark theme styling via CSS custom properties. +// ============================================================================ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** + * Displays a determinate progress bar with an optional elapsed time indicator. + * + * Usage: + * ```html + * + * + * ``` + */ +@Component({ + selector: 'app-task-progress-bar', + standalone: true, + imports: [CommonModule, MatProgressBarModule], + templateUrl: './task-progress-bar.component.html', + styleUrl: './task-progress-bar.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TaskProgressBarComponent implements OnInit, OnDestroy { + // --------------------------------------------------------------------------- + // Inputs + // --------------------------------------------------------------------------- + + /** Current progress percentage (0–100). Required. */ + @Input({ required: true }) + progress!: number; + + /** Whether to show elapsed time next to the percentage. Defaults to false. */ + @Input() + showElapsed = false; + + // --------------------------------------------------------------------------- + // Internal state + // --------------------------------------------------------------------------- + + /** Timestamp when the component initialized — used for elapsed calculation. */ + startTime = Date.now(); + + /** Formatted elapsed time string, e.g. "2m 15s ago". */ + elapsedText = ''; + + /** Interval timer for updating the elapsed display. */ + private timer: ReturnType | null = null; + + constructor(private cdr: ChangeDetectorRef) {} + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + ngOnInit(): void { + this.updateElapsed(); + + if (this.showElapsed) { + // Update elapsed time every second + this.timer = setInterval(() => { + this.updateElapsed(); + this.cdr.markForCheck(); + }, 1000); + } + } + + ngOnDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** Clamp progress to 0–100 for safety. */ + get clampedProgress(): number { + return Math.max(0, Math.min(100, this.progress ?? 0)); + } + + /** Recalculate the elapsed time string. */ + private updateElapsed(): void { + const elapsedMs = Date.now() - this.startTime; + const totalSeconds = Math.floor(elapsedMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes > 0) { + this.elapsedText = `${minutes}m ${seconds}s ago`; + } else { + this.elapsedText = `${seconds}s ago`; + } + } +} \ No newline at end of file