Compare commits

..

19 Commits

Author SHA1 Message Date
5147997764 CUB-125: implement real-time SSE/WebSocket in React frontend
Some checks failed
Dev Build / build-test (pull_request) Failing after 58s
- Add useSSE hook with exponential back-off reconnect (1s → 30s)
- Add useRealtimeSync hook: maps SSE events to React Query invalidation
  (agent.status → agents; agent.task/agent.progress → tasks+agents; fleet.update → all)
- Add SSEContext/SSEProvider so connection status is available app-wide
- Mount SSEProvider in main.tsx inside QueryClientProvider (no polling)
- Show live/connecting/reconnecting/disconnected badge in sidebar + mobile header
- Update SettingsPage: replace polling interval UI with SSE status panel
- Disable React Query polling (staleTime 60s); all updates pushed via SSE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:10:38 -04:00
17a28a33e1 CUB-119: Add docker-compose.yml + .env.example, remove legacy .NET backend and Angular frontend
Some checks failed
Dev Build / build-test (pull_request) Failing after 1m36s
2026-05-10 08:21:33 -04:00
9a802b4212 Merge pull request 'CUB-123: Integrate gateway, wire PostgreSQL repositories, add SSE streaming' (#37) from agent/dex/CUB-123-gateway-integration into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m23s
Reviewed-on: #37
2026-05-08 21:55:48 -04:00
1a50306f7d Merge branch 'dev' into agent/dex/CUB-123-gateway-integration
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m22s
2026-05-08 21:55:38 -04:00
e8ced74429 CUB-123: integrate gateway, wire PostgreSQL repositories, add SSE streaming
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m23s
- Create repository/ package with pgx-backed CRUD for agents, sessions, tasks, projects
- Define AgentRepo/SessionRepo/TaskRepo/ProjectRepo interfaces
- Update handler to use repository interfaces instead of in-memory stores
- Add SSE broker with GET /api/events endpoint (text/event-stream)
- Add gateway client that polls OpenClaw for agent states
- Add GATEWAY_URL and GATEWAY_POLL_INTERVAL config fields
- Seed 5 demo agents (Otto, Rex, Dex, Hex, Pip) on empty DB
- Update router to wire SSE broker
- All 21 handler tests pass with mock repos
2026-05-08 19:58:06 -04:00
8b8cb8210c CUB-121: build React pages with real API integration
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m11s
Dev Build / build-test (push) Successful in 2m18s
- HubPage: agent summary stats, cards, status badges, progress bars, refresh
- LogsPage: activity feed from tasks, status filter, loading skeleton
- ProjectsPage: project cards with status badges and agent counts
- SessionsPage: responsive table/card view with model/token info
- SettingsPage: dark mode toggle, gateway URL, refresh interval persist
- ThemeProvider with dark/light mode via CSS custom properties
- useLocalStorage hook for settings persistence
- Loading/error/empty states across all pages
- npm run build passes cleanly
2026-05-08 19:53:21 -04:00
4a2e660a4a Merge remote-tracking branch 'origin/agent/rex/CUB-122-scaffold-react-frontend' into dev
All checks were successful
Dev Build / build-test (push) Successful in 1m56s
2026-05-08 14:28:58 -04:00
07d40d729f Merge remote-tracking branch 'origin/agent/hex/CUB-120-postgresql-schema' into dev 2026-05-08 14:28:55 -04:00
8da593c450 CUB-122: Scaffold Control Center React frontend
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m57s
2026-05-07 20:15:30 -04:00
437a519c36 CUB-120: design PostgreSQL schema for Control Center Go backend
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m13s
2026-05-07 14:22:00 -04:00
c906cd46ad CUB-124: scaffold Control Center Go backend
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m6s
2026-05-07 14:16:05 -04:00
cce3e061a7 CUB-127: implement Control Center CRUD API in Go
Some checks failed
Dev Build / build-test (pull_request) Failing after 11m6s
Dev Build / build-test (push) Successful in 1m54s
2026-05-06 17:29:44 -04:00
ab19a7ccde Merge pull request 'CUB-20: Agent card component with dynamic status/progress' (#28) from agent/rex/CUB-20-agent-card-component into dev
All checks were successful
Dev Build / build-test (push) Successful in 3m30s
2026-04-29 11:29:23 -04:00
745994182f CUB-20: Develop agent card component with dynamic status/progress
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m10s
2026-04-29 11:02:33 -04:00
1775c25b61 Merge pull request 'CUB-27: Responsive layout and adaptive navigation' (#29) from agent/rex/CUB-27-responsive-layout-adaptive-nav into dev
All checks were successful
Dev Build / build-test (push) Successful in 3m5s
2026-04-29 10:53:17 -04:00
999f6614ce CUB-27: Responsive layout and adaptive navigation
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m46s
2026-04-29 10:22:18 -04:00
048101e85c Merge pull request 'CUB-26: Quick-Jump Drawer & Modal Components' (#30) from agent/rex/CUB-26-quick-jump-drawer-modal into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m15s
2026-04-29 10:16:05 -04:00
dcfa4dc2a2 Merge branch 'dev' into agent/rex/CUB-26-quick-jump-drawer-modal
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m20s
2026-04-29 10:13:25 -04:00
e84a479e33 CUB-26: Quick-jump drawer and modal components
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m5s
2026-04-28 09:14:30 -04:00
174 changed files with 6710 additions and 14546 deletions

33
.env.example Normal file
View File

@@ -0,0 +1,33 @@
# =============================================================================
# Control Center — Environment Configuration Template
# =============================================================================
# Copy this file to `.env` and fill in real values.
# Never commit `.env` — it is in .gitignore by default.
# =============================================================================
# ── Database ────────────────────────────────────────────────────────────────
POSTGRES_DB=controlcenter
POSTGRES_USER=controlcenter
POSTGRES_PASSWORD=changeme
POSTGRES_PORT=5432
# ── Backend ───────────────────────────────────────────────────────────────────
BACKEND_PORT=8080
LOG_LEVEL=info
ENVIRONMENT=development
# ── Frontend ────────────────────────────────────────────────────────────────
FRONTEND_PORT=3000
# ── CORS ────────────────────────────────────────────────────────────────────
# Comma-separated allowed origins. Use "*" for local dev.
CORS_ORIGIN=http://localhost:3000
# ── OpenClaw Gateway ──────────────────────────────────────────────────────────
# URL to the OpenClaw gateway agent status endpoint.
# In Docker Compose, use the container name or host.docker.internal for the
# gateway running outside Compose.
GATEWAY_URL=http://host.docker.internal:18789/api/agents
# How often to poll the gateway for agent state updates.
GATEWAY_POLL_INTERVAL=5s

21
backend/.gitignore vendored
View File

@@ -1,21 +0,0 @@
## .NET
bin/
obj/
*.user
*.suo
*.cache
*.dll
*.pdb
## IDE
.vs/
.idea/
*.swp
*~
## OS
.DS_Store
Thumbs.db
## Environment
.env

View File

@@ -1,88 +0,0 @@
using ControlCenter.Api.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ControlCenter.Api.Configurations;
/// <summary>
/// EF Core entity type configuration for the agents table.
/// Enforces snake_case naming, required fields, and index design.
/// </summary>
public class AgentConfiguration : IEntityTypeConfiguration<Agent>
{
public void Configure(EntityTypeBuilder<Agent> builder)
{
// Table name — snake_case
builder.ToTable("agents");
// Primary key
builder.HasKey(a => a.Id);
builder.Property(a => a.Id)
.HasColumnName("id")
.ValueGeneratedOnAdd();
// Status — stored as PostgreSQL enum via Npgsql
builder.Property(a => a.Status)
.HasColumnName("status")
.HasColumnType("agent_status")
.IsRequired();
// Task — nullable text
builder.Property(a => a.Task)
.HasColumnName("task")
.HasColumnType("text");
// Progress — nullable integer (0100)
builder.Property(a => a.Progress)
.HasColumnName("progress");
// Session key — required, not null
builder.Property(a => a.SessionKey)
.HasColumnName("session_key")
.HasColumnType("text")
.IsRequired();
// Channel — required, not null
builder.Property(a => a.Channel)
.HasColumnName("channel")
.HasColumnType("text")
.IsRequired();
// Last activity — required, defaults to now()
builder.Property(a => a.LastActivity)
.HasColumnName("last_activity")
.HasColumnType("timestamptz")
.IsRequired();
// Created at — auto-set on insert
builder.Property(a => a.CreatedAt)
.HasColumnName("created_at")
.HasColumnType("timestamptz")
.IsRequired()
.HasDefaultValueSql("now()");
// Updated at — auto-set on insert and update
builder.Property(a => a.UpdatedAt)
.HasColumnName("updated_at")
.HasColumnType("timestamptz")
.IsRequired()
.HasDefaultValueSql("now()");
// Indexes
// Sessions are looked up by session_key frequently
builder.HasIndex(a => a.SessionKey)
.HasDatabaseName("ix_agents_session_key")
.IsUnique();
// Agents are filtered by channel for channel-specific queries
builder.HasIndex(a => a.Channel)
.HasDatabaseName("ix_agents_channel");
// Agents are filtered by status for fleet health monitoring
builder.HasIndex(a => a.Status)
.HasDatabaseName("ix_agents_status");
// Check constraint: progress must be 0100 if present
builder.ToTable(t => t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)"));
}
}

View File

@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- Exclude the separate ControlCenter sub-project from this project's compilation -->
<ItemGroup>
<Compile Remove="ControlCenter/**/*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
@ControlCenter.Api_HostAddress = http://localhost:5178
GET {{ControlCenter.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,18 +0,0 @@
## .NET
bin/
obj/
*.user
*.suo
*.cache
*.dll
*.pdb
*.xml
## IDE
.vs/
.vscode/
.idea/
## OS
.DS_Store
Thumbs.db

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>CS1591</NoWarn>
<RootNamespace>ControlCenter</RootNamespace>
<AssemblyName>ControlCenter</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
</Project>

View File

@@ -1,71 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using ControlCenter.Services;
namespace ControlCenter.Controllers;
/// <summary>
/// REST API for querying agent fleet status.
/// Provides the initial data load for the Command Hub,
/// while real-time updates flow through the AgentStatus SignalR hub.
///
/// <para>API contract for Rex (Frontend):</para>
/// <list type="bullet">
/// <item><c>GET /api/agents</c> — Returns all known agents with current status</item>
/// <item><c>GET /api/agents/{agentId}</c> — Returns a specific agent's status</item>
/// </list>
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class AgentsController : ControllerBase
{
private readonly ILogger<AgentsController> _logger;
private readonly GatewayEventBridgeService _bridgeService;
public AgentsController(
ILogger<AgentsController> logger,
GatewayEventBridgeService bridgeService)
{
_logger = logger;
_bridgeService = bridgeService;
}
/// <summary>
/// Gets the current fleet status — all known agents with their latest state.
/// This is the initial load endpoint; subsequent updates arrive via SignalR.
/// </summary>
/// <returns>An array of agent card data for the entire fleet.</returns>
/// <response code="200">Returns the fleet snapshot.</response>
[HttpGet]
[ProducesResponseType(typeof(AgentCardData[]), StatusCodes.Status200OK)]
public IActionResult GetAgents()
{
var snapshot = _bridgeService.GetFleetSnapshot();
_logger.LogDebug("Fleet snapshot requested: {Count} agents", snapshot.Length);
return Ok(snapshot);
}
/// <summary>
/// Gets the current status of a specific agent.
/// </summary>
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
/// <returns>The agent's current card data.</returns>
/// <response code="200">Returns the agent's status.</response>
/// <response code="404">Agent not found in the fleet state.</response>
[HttpGet("{agentId}")]
[ProducesResponseType(typeof(AgentCardData), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetAgent(string agentId)
{
var snapshot = _bridgeService.GetFleetSnapshot();
var agent = snapshot.FirstOrDefault(a =>
a.Id.Equals(agentId, StringComparison.OrdinalIgnoreCase));
if (agent is null)
{
_logger.LogWarning("Agent not found: {AgentId}", agentId);
return NotFound(new { error = $"Agent '{agentId}' not found" });
}
return Ok(agent);
}
}

View File

@@ -1,122 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace ControlCenter.Controllers;
/// <summary>
/// REST API for sending control commands to agents.
/// Provides the Command Hub's action endpoints for agent lifecycle control.
///
/// <para>API contract for Rex (Frontend):</para>
/// <list type="bullet">
/// <item><c>POST /api/command/stop/{agentId}</c> — Stop/abort an agent's active session</item>
/// <item><c>POST /api/command/restart/{agentId}</c> — Restart an agent</item>
/// <item><c>POST /api/command/steer/{agentId}</c> — Inject a message into an agent's session</item>
/// </list>
///
/// <para>Commands are forwarded to the OpenClaw Gateway via the
/// WebSocket bridge service. The Gateway handles the actual execution.</para>
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class CommandController : ControllerBase
{
private readonly ILogger<CommandController> _logger;
public CommandController(ILogger<CommandController> logger)
{
_logger = logger;
}
/// <summary>
/// Stops (aborts) an agent's active session.
/// Sends an abort command to the OpenClaw Gateway.
/// </summary>
/// <param name="agentId">The agent identifier to stop.</param>
/// <returns>Confirmation of the stop command.</returns>
/// <response code="200">Stop command sent successfully.</response>
/// <response code="404">No active session found for the agent.</response>
[HttpPost("stop/{agentId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult StopAgent(string agentId)
{
_logger.LogInformation("Stop command received for agent {AgentId}", agentId);
// TODO: Forward to Gateway via bridge service
// await _bridgeService.SendRpcAsync("sessions.abort", new { sessionKey = ... });
return Ok(new
{
agentId,
command = "stop",
status = "sent",
timestamp = DateTime.UtcNow.ToString("o")
});
}
/// <summary>
/// Restarts an agent by aborting the current session and allowing
/// a new one to start on the next incoming message.
/// </summary>
/// <param name="agentId">The agent identifier to restart.</param>
/// <returns>Confirmation of the restart command.</returns>
/// <response code="200">Restart command sent successfully.</response>
[HttpPost("restart/{agentId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult RestartAgent(string agentId)
{
_logger.LogInformation("Restart command received for agent {AgentId}", agentId);
// TODO: Forward to Gateway — abort current session + signal restart
return Ok(new
{
agentId,
command = "restart",
status = "sent",
timestamp = DateTime.UtcNow.ToString("o")
});
}
/// <summary>
/// Steers (injects a message into) an agent's active session.
/// Used by operators to redirect an agent's task mid-execution.
/// </summary>
/// <param name="agentId">The agent identifier to steer.</param>
/// <param name="request">The steering message to inject.</param>
/// <returns>Confirmation of the steer command.</returns>
/// <response code="200">Steer command sent successfully.</response>
/// <response code="400">Missing or empty message.</response>
[HttpPost("steer/{agentId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult SteerAgent(string agentId, [FromBody] SteerRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
{
return BadRequest(new { error = "Message is required" });
}
_logger.LogInformation("Steer command received for agent {AgentId}: {Message}",
agentId, request.Message.Length > 100
? request.Message[..100] + "..." : request.Message);
// TODO: Forward to Gateway via bridge service
// await _bridgeService.SendRpcAsync("sessions.steer", new { sessionKey = ..., message = request.Message });
return Ok(new
{
agentId,
command = "steer",
message = request.Message,
status = "sent",
timestamp = DateTime.UtcNow.ToString("o")
});
}
}
/// <summary>
/// Request body for the steer command.
/// </summary>
/// <param name="Message">The message to inject into the agent's session.</param>
public record SteerRequest(string Message);

View File

@@ -1,87 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace ControlCenter.Controllers;
/// <summary>
/// REST API for querying agent session logs.
/// Provides historical message and tool call logs for a specific agent.
///
/// <para>API contract for Rex (Frontend):</para>
/// <list type="bullet">
/// <item><c>GET /api/logs/{agentId}</c> — Returns recent logs for an agent</item>
/// <item><c>GET /api/logs/{agentId}/tools</c> — Returns recent tool calls for an agent</item>
/// </list>
///
/// <para>Log data is sourced from the OpenClaw Gateway's transcript files.
/// The Gateway's <c>logs.tail</c> RPC provides the raw data, and this
/// controller formats it for the frontend.</para>
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class LogsController : ControllerBase
{
private readonly ILogger<LogsController> _logger;
public LogsController(ILogger<LogsController> logger)
{
_logger = logger;
}
/// <summary>
/// Gets recent session logs for a specific agent.
/// Returns the last N messages from the agent's active session transcript.
/// </summary>
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
/// <param name="limit">Maximum number of log entries to return (default: 50, max: 200).</param>
/// <returns>An array of log entries for the agent.</returns>
/// <response code="200">Returns the agent's recent logs.</response>
/// <response code="404">No active session found for the agent.</response>
[HttpGet("{agentId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetLogs(string agentId, [FromQuery] int limit = 50)
{
limit = Math.Clamp(limit, 1, 200);
_logger.LogDebug("Logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
// TODO: Implement log retrieval by calling the Gateway's logs.tail RPC
// or reading transcript files. For now, return an empty array as the
// bridge service will provide this data when fully integrated.
return Ok(new
{
agentId,
logs = Array.Empty<object>(),
count = 0,
hasMore = false
});
}
/// <summary>
/// Gets recent tool call logs for a specific agent.
/// Returns the last N tool invocations from the agent's session.
/// </summary>
/// <param name="agentId">The agent identifier.</param>
/// <param name="limit">Maximum number of tool entries to return (default: 20, max: 100).</param>
/// <returns>An array of tool call entries for the agent.</returns>
/// <response code="200">Returns the agent's recent tool calls.</response>
/// <response code="404">No active session found for the agent.</response>
[HttpGet("{agentId}/tools")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetToolLogs(string agentId, [FromQuery] int limit = 20)
{
limit = Math.Clamp(limit, 1, 100);
_logger.LogDebug("Tool logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
// TODO: Implement tool log retrieval. Return empty for now.
return Ok(new
{
agentId,
tools = Array.Empty<object>(),
count = 0,
hasMore = false
});
}
}

View File

@@ -1,211 +0,0 @@
using Microsoft.AspNetCore.SignalR;
namespace ControlCenter.Hubs;
/// <summary>
/// SignalR hub for real-time agent status updates in the Command Hub.
///
/// <para>Usage flow:</para>
/// <list type="number">
/// <item>Client connects to <c>/hubs/agent-status</c></item>
/// <item>Client calls <see cref="JoinFleet"/> to subscribe to all agent updates</item>
/// <item>Client calls <see cref="JoinAgentGroup"/> to subscribe to a specific agent</item>
/// <item>Server pushes <see cref="IAgentStatusClient.AgentStatusChanged"/>
/// and <see cref="IAgentStatusClient.AgentTaskProgress"/> events</item>
/// <item>Client calls <see cref="GetFleetSnapshot"/> for initial state on connect</item>
/// </list>
///
/// <para>Group naming:</para>
/// <list type="bullet">
/// <item>Fleet group: <c>fleet</c> — receives all agent updates</item>
/// <item>Agent group: <c>agent:{agentId}</c> — receives updates for one agent</item>
/// </list>
///
/// <para>Typed client: <see cref="IAgentStatusClient"/> — all server-to-client
/// calls go through this interface for compile-time safety.</para>
///
/// <para>Architecture note: This hub bridges OpenClaw Gateway WebSocket events
/// to SignalR clients. A background service (<see cref="Services.GatewayEventBridgeService"/>)
/// subscribes to Gateway events and pushes them through this hub's extension methods.</para>
/// </summary>
public class AgentStatusHub : Hub<IAgentStatusClient>
{
private readonly ILogger<AgentStatusHub> _logger;
public AgentStatusHub(ILogger<AgentStatusHub> logger)
{
_logger = logger;
}
/// <summary>
/// Broadcasts an agent status update to all connected clients.
///
/// <para>
/// 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 <see cref="AgentStatusUpdate"/> record and relayed through the
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
/// </para>
/// </summary>
/// <param name="update">The agent status update DTO to broadcast.</param>
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);
}
/// <summary>
/// Adds the calling connection to the fleet group.
/// Once joined, the client will receive all agent status changes
/// and task progress updates across the entire fleet.
/// </summary>
public async Task JoinFleet()
{
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
}
/// <summary>
/// Removes the calling connection from the fleet group.
/// </summary>
public async Task LeaveFleet()
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
}
/// <summary>
/// Adds the calling connection to a specific agent's group.
/// Once joined, the client will receive updates only for that agent.
/// </summary>
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
/// <exception cref="HubException">Thrown if agentId is null or empty.</exception>
public async Task JoinAgentGroup(string agentId)
{
if (string.IsNullOrWhiteSpace(agentId))
throw new HubException("agentId is required");
var groupName = AgentGroupName(agentId);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
_logger.LogDebug("Connection {ConnectionId} joined agent group {GroupName}",
Context.ConnectionId, groupName);
}
/// <summary>
/// Removes the calling connection from a specific agent's group.
/// </summary>
/// <param name="agentId">The agent identifier.</param>
public async Task LeaveAgentGroup(string agentId)
{
if (string.IsNullOrWhiteSpace(agentId)) return;
var groupName = AgentGroupName(agentId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// Returns a snapshot of the current fleet state.
/// Called by clients on initial connection to get the full picture
/// before incremental updates begin arriving.
/// </summary>
/// <returns>An array of <see cref="AgentCardData"/> representing all known agents.</returns>
public Task<AgentCardData[]> GetFleetSnapshot()
{
// The fleet state is managed by the GatewayEventBridgeService.
// For now, return an empty array — the bridge service will push
// updates as they arrive from the Gateway.
_logger.LogDebug("Fleet snapshot requested by {ConnectionId}", Context.ConnectionId);
return Task.FromResult(Array.Empty<AgentCardData>());
}
/// <summary>
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
/// SignalR automatically removes disconnected connections from all groups.
/// </summary>
/// <param name="exception">Exception that caused the disconnection, if any.</param>
public override Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
/// <summary>
/// The SignalR group name for the entire fleet (all agents).
/// </summary>
internal const string FleetGroupName = "fleet";
/// <summary>
/// Returns the SignalR group name for a specific agent.
/// Format: <c>agent:{agentId}</c> (lowercase for consistency).
/// </summary>
/// <param name="agentId">The agent identifier.</param>
internal static string AgentGroupName(string agentId) =>
$"agent:{agentId.ToLowerInvariant()}";
}
/// <summary>
/// Extension methods for pushing real-time agent updates through
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
///
/// <para>These methods are intended to be called from background services
/// (e.g., <see cref="Services.GatewayEventBridgeService"/>) or other
/// server-side code that detects an agent state change.</para>
/// </summary>
public static class AgentStatusHubExtensions
{
/// <summary>
/// Pushes an agent status change to all clients subscribed to
/// the fleet group and the specific agent's group.
///
/// <para>Call this from any background service when an agent's
/// operational status changes (e.g., the Gateway reports a
/// session transition from "running" to "done").</para>
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="update">The agent status update payload.</param>
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
public static async Task PushAgentStatusAsync(
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
AgentStatusUpdate update)
{
// Broadcast to the fleet group (all subscribers)
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
.AgentStatusChanged(update);
// Also push to the specific agent's group
var agentGroup = AgentStatusHub.AgentGroupName(update.AgentId);
await hubContext.Clients.Group(agentGroup)
.AgentStatusChanged(update);
}
/// <summary>
/// Pushes a task progress update to all clients subscribed to
/// the fleet group and the specific agent's group.
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="progress">The task progress update payload.</param>
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
public static async Task PushTaskProgressAsync(
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
TaskProgressUpdate progress)
{
// Broadcast to the fleet group
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
.AgentTaskProgress(progress);
// Also push to the specific agent's group
var agentGroup = AgentStatusHub.AgentGroupName(progress.AgentId);
await hubContext.Clients.Group(agentGroup)
.AgentTaskProgress(progress);
}
}

View File

@@ -1,30 +0,0 @@
namespace ControlCenter.Hubs;
/// <summary>
/// Strongly-typed client interface for the AgentStatus SignalR hub.
/// Defines the methods the server can invoke on connected clients
/// to push real-time agent status and task progress updates.
///
/// <para>All server-to-client calls go through this interface for
/// compile-time safety — matching the pattern used by the
/// Extrudex PrinterHub.</para>
/// </summary>
public interface IAgentStatusClient
{
/// <summary>
/// Pushes an agent status change to all subscribed clients.
/// Fired whenever an agent's operational status changes
/// (e.g., idle → active, active → thinking, active → error).
/// </summary>
/// <param name="update">The full status update payload.</param>
/// <returns>A Task that completes when the client has processed the update.</returns>
Task AgentStatusChanged(AgentStatusUpdate update);
/// <summary>
/// Pushes a task progress update to all subscribed clients.
/// Fired when an agent reports progress on its current task.
/// </summary>
/// <param name="progress">The task progress update payload.</param>
/// <returns>A Task that completes when the client has processed the update.</returns>
Task AgentTaskProgress(TaskProgressUpdate progress);
}

View File

@@ -1,166 +0,0 @@
namespace ControlCenter;
/// <summary>
/// Agent operational status derived from OpenClaw Gateway session activity.
/// Maps to the frontend AgentStatus type: 'active' | 'idle' | 'thinking' | 'error'.
/// </summary>
public enum AgentStatus
{
/// <summary>Agent is currently processing a turn.</summary>
Active,
/// <summary>Agent completed its last turn; no active work.</summary>
Idle,
/// <summary>LLM call in flight; tokens streaming.</summary>
Thinking,
/// <summary>Agent encountered an unhandled error.</summary>
Error
}
/// <summary>
/// Extended lifecycle status including offline — not all agents have active sessions.
/// Used internally; clients only see <see cref="AgentStatus"/> (offline maps to idle).
/// </summary>
public enum AgentLifecycleStatus
{
Active,
Idle,
Thinking,
Error,
Offline
}
/// <summary>
/// Pushed to SignalR clients when an agent's status changes.
/// Matches the TypeScript <c>AgentStatusUpdate</c> interface from the design spec.
/// </summary>
/// <param name="AgentId">Agent identifier, e.g. "otto", "dex".</param>
/// <param name="DisplayName">Human-readable name, e.g. "Otto".</param>
/// <param name="Role">Role description, e.g. "Orchestrator Agent".</param>
/// <param name="Status">Current operational status.</param>
/// <param name="CurrentTask">Description of the current task, if any.</param>
/// <param name="SessionKey">Full session key, e.g. "agent:otto:telegram:direct:8787451565".</param>
/// <param name="Channel">Channel the agent is operating on, e.g. "telegram".</param>
/// <param name="LastActivity">ISO 8601 timestamp of last activity.</param>
/// <param name="ErrorMessage">Error message when status is 'error'.</param>
public record AgentStatusUpdate(
string AgentId,
string DisplayName,
string Role,
string Status,
string? CurrentTask,
string SessionKey,
string Channel,
string LastActivity,
string? ErrorMessage = null
);
/// <summary>
/// Pushed to SignalR clients when an agent's task progress updates.
/// Matches the TypeScript <c>TaskProgressUpdate</c> interface from the design spec.
/// </summary>
/// <param name="AgentId">Agent identifier.</param>
/// <param name="TaskDescription">Description of the current task.</param>
/// <param name="Progress">Task progress percentage (0100), if trackable.</param>
/// <param name="Elapsed">Elapsed time string, e.g. "04m 12s".</param>
public record TaskProgressUpdate(
string AgentId,
string TaskDescription,
int? Progress,
string? Elapsed
);
/// <summary>
/// Data transfer object for broadcasting agent status updates
/// to all connected SignalR clients via the hub's SendStatusUpdate method.
///
/// <para>This DTO provides a mutable, serialization-friendly alternative to
/// <see cref="AgentStatusUpdate"/> for callers that construct updates
/// from external data sources (e.g., HTTP API payloads).</para>
/// </summary>
public class AgentStatusUpdateDto
{
/// <summary>
/// Agent identifier, e.g. "otto", "dex", "rex".
/// </summary>
public string AgentId { get; set; } = string.Empty;
/// <summary>
/// Human-readable display name, e.g. "Otto", "Dex".
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// Current operational status of the agent as lowercase string:
/// "active", "idle", "thinking", "error".
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Description of the agent's current task, if any.
/// </summary>
public string? CurrentTask { get; set; }
/// <summary>
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
/// </summary>
public string SessionKey { get; set; } = string.Empty;
/// <summary>
/// Communication channel, e.g. "telegram", "discord", "slack".
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// ISO 8601 timestamp of the agent's last activity.
/// </summary>
public string LastActivity { get; set; } = string.Empty;
/// <summary>
/// Error message when the agent status is "error".
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Converts this DTO to an immutable <see cref="AgentStatusUpdate"/> record
/// for use with the typed SignalR client interface.
/// </summary>
/// <returns>An <see cref="AgentStatusUpdate"/> with equivalent field values.</returns>
public AgentStatusUpdate ToUpdate() => new(
AgentId,
DisplayName,
Role,
Status,
CurrentTask,
SessionKey,
Channel,
LastActivity,
ErrorMessage
);
}
/// <summary>
/// Snapshot of an agent's full card data, sent on initial connection
/// or when the fleet state is requested.
/// Matches the TypeScript <c>AgentCardData</c> interface from the design spec.
/// </summary>
public record AgentCardData(
string Id,
string DisplayName,
string Role,
string Status,
string? CurrentTask,
int? TaskProgress,
string? TaskElapsed,
string SessionKey,
string Channel,
string LastActivity,
string? ErrorMessage
);

View File

@@ -1,72 +0,0 @@
namespace ControlCenter.Models;
/// <summary>
/// Defines which side of the Control Center dashboard a minion occupies.
/// </summary>
public enum MinionSide
{
/// <summary>Development side — Rex, Dex, Hex.</summary>
Dev,
/// <summary>Business side — Larry, Mel, Buzz.</summary>
Business
}
/// <summary>
/// Visual state of a minion sprite, derived from the agent's
/// <see cref="AgentStatus"/>. Maps Active/Idle/Thinking/Error
/// to frontend animation states.
/// </summary>
public enum MinionState
{
/// <summary>Agent is actively processing — minion shows working animation.</summary>
Active,
/// <summary>Agent is idle — minion shows idle/patrolling animation.</summary>
Idle,
/// <summary>Agent is thinking (LLM call in flight) — minion shows thinking animation.</summary>
Thinking,
/// <summary>Agent encountered an error — minion shows error/distress animation.</summary>
Error
}
/// <summary>
/// Static mapping entry that associates an agent ID with a minion's
/// display side and position index within that side.
///
/// <para>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.</para>
/// </summary>
/// <param name="AgentId">Agent identifier, e.g. "rex", "dex".</param>
/// <param name="Side">Which side of the dashboard the minion occupies.</param>
/// <param name="PositionIndex">Zero-based position index within the side.</param>
/// <param name="DisplayName">Human-readable name, e.g. "Rex".</param>
public record AgentMinionMapping(
string AgentId,
MinionSide Side,
int PositionIndex,
string DisplayName
);
/// <summary>
/// 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).
/// </summary>
/// <param name="AgentId">Agent identifier, e.g. "rex".</param>
/// <param name="DisplayName">Human-readable minion name, e.g. "Rex".</param>
/// <param name="Side">Which side of the dashboard — Dev or Business.</param>
/// <param name="PositionIndex">Position within the side (0-based).</param>
/// <param name="State">Current minion animation state.</param>
/// <param name="Timestamp">ISO 8601 timestamp of the state change.</param>
public record MinionStateUpdate(
string AgentId,
string DisplayName,
MinionSide Side,
int PositionIndex,
MinionState State,
string Timestamp
);

View File

@@ -1,77 +0,0 @@
using System.Reflection;
using ControlCenter.Hubs;
using ControlCenter.Services;
var builder = WebApplication.CreateBuilder(args);
// ── API Services ───────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new()
{
Title = "Control Center API",
Version = "v1",
Description = "OpenClaw Control Center — Command Hub backend with SignalR real-time updates"
});
// Include XML doc comments in Swagger output
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
c.IncludeXmlComments(xmlPath);
}
});
// ── CORS (kiosk + remote browser) ─────────────────────────
// The Control Center frontend runs on a different origin than the backend.
// SignalR requires credentials for WebSocket transport, so we use
// specific origins rather than AllowAnyOrigin.
var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? new[] { "http://localhost:4200", "http://localhost:5000" };
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(corsOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials(); // Required for SignalR WebSocket
});
});
// ── SignalR (real-time agent status updates) ───────────────
builder.Services.AddSignalR();
// ── Gateway Bridge Service ────────────────────────────────
// Background service that connects to the OpenClaw Gateway WebSocket
// and bridges events to the AgentStatus SignalR hub.
builder.Services.AddSingleton<GatewayEventBridgeService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GatewayEventBridgeService>());
// ── Agent-Minion Mapper Service ────────────────────────────
// Maps agents to minion sprites/positions and publishes state
// updates through SignalR.
builder.Services.AddSingleton<AgentMinionMapperService>();
var app = builder.Build();
// ── Middleware ──────────────────────────────────────────────
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseAuthorization();
app.MapControllers();
// ── Hub Endpoints ───────────────────────────────────────────
// Agent status hub at /hubs/agent-status (matches the design spec)
app.MapHub<AgentStatusHub>("/hubs/agent-status");
app.Run();

View File

@@ -1,14 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5053",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,193 +0,0 @@
using ControlCenter.Hubs;
using ControlCenter.Models;
using Microsoft.AspNetCore.SignalR;
namespace ControlCenter.Services;
/// <summary>
/// Service that maps Linear agents to minion sprites and positions
/// in the Control Center dashboard.
///
/// <para>Static mappings define where each minion appears:</para>
/// <list type="bullet">
/// <item>Dev side: Rex (0), Dex (1), Hex (2)</item>
/// <item>Business side: Larry (0), Mel (1), Buzz (2)</item>
/// </list>
///
/// <para>Dynamic state is derived from the agent's <see cref="AgentStatus"/>:</para>
/// <list type="bullet">
/// <item><c>Active</c> → <see cref="MinionState.Active"/></item>
/// <item><c>Idle</c> → <see cref="MinionState.Idle"/></item>
/// <item><c>Thinking</c> → <see cref="MinionState.Thinking"/></item>
/// <item><c>Error</c> → <see cref="MinionState.Error"/></item>
/// </list>
///
/// <para>State updates are published through the <see cref="AgentStatusHub"/>
/// SignalR hub so that connected clients can animate minion sprites
/// in real time.</para>
/// </summary>
public class AgentMinionMapperService
{
private readonly ILogger<AgentMinionMapperService> _logger;
private readonly IHubContext<AgentStatusHub, IAgentStatusClient> _hubContext;
/// <summary>
/// Static agent-to-minion mapping table. Defines which side and position
/// each agent's minion occupies on the dashboard.
/// </summary>
private static readonly Dictionary<string, AgentMinionMapping> 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"),
};
/// <summary>
/// Maps <see cref="AgentStatus"/> string values to <see cref="MinionState"/>.
/// </summary>
private static readonly Dictionary<string, MinionState> StatusToMinionState = new()
{
["active"] = MinionState.Active,
["idle"] = MinionState.Idle,
["thinking"] = MinionState.Thinking,
["error"] = MinionState.Error,
};
public AgentMinionMapperService(
ILogger<AgentMinionMapperService> logger,
IHubContext<AgentStatusHub, IAgentStatusClient> hubContext)
{
_logger = logger;
_hubContext = hubContext;
}
/// <summary>
/// Gets the minion mapping for a given agent ID.
/// Returns null if the agent is not mapped to a minion position.
/// </summary>
/// <param name="agentId">The agent identifier, e.g. "rex", "dex".</param>
/// <returns>The mapping record, or null if unmapped.</returns>
public AgentMinionMapping? GetMapping(string agentId)
{
return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty);
}
/// <summary>
/// Gets all minion mappings, ordered by side then position index.
/// </summary>
/// <returns>All mappings, sorted for consistent display order.</returns>
public IReadOnlyList<AgentMinionMapping> GetAllMappings()
{
return Mappings.Values
.OrderBy(m => m.Side)
.ThenBy(m => m.PositionIndex)
.ToList();
}
/// <summary>
/// Converts an agent status string to a <see cref="MinionState"/>.
/// Falls back to <see cref="MinionState.Idle"/> for unrecognized statuses.
/// </summary>
/// <param name="status">Agent status string: "active", "idle", "thinking", or "error".</param>
/// <returns>The corresponding minion state.</returns>
public MinionState StatusToState(string status)
{
return StatusToMinionState.GetValueOrDefault(
status?.ToLowerInvariant() ?? string.Empty,
MinionState.Idle);
}
/// <summary>
/// Publishes a minion state update through SignalR when an agent's
/// status changes. Only publishes for agents that have a minion mapping.
///
/// <para>This is the primary integration point: the
/// <see cref="GatewayEventBridgeService"/> calls this method
/// whenever it detects a status change from the OpenClaw Gateway.</para>
/// </summary>
/// <param name="agentId">The agent whose status changed, e.g. "dex".</param>
/// <param name="status">The new status string: "active", "idle", "thinking", or "error".</param>
/// <returns>A task that completes when the SignalR message has been sent.</returns>
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);
}
/// <summary>
/// Gets the current minion state for all mapped agents, suitable
/// for building an initial fleet snapshot.
/// </summary>
/// <returns>All minion mappings with their current (idle) state.</returns>
public IReadOnlyList<MinionStateUpdate> 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();
}
/// <summary>
/// Converts a status string to an <see cref="AgentStatusUpdate"/>
/// for SignalR push. Uses the mapping table for display names and roles.
/// </summary>
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
);
}
}

View File

@@ -1,523 +0,0 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using ControlCenter.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace ControlCenter.Services;
/// <summary>
/// Background service that connects to the OpenClaw Gateway WebSocket
/// and bridges Gateway events to the <see cref="Hubs.AgentStatusHub"/>.
///
/// <para>Architecture:</para>
/// <list type="number">
/// <item>Connects to the Gateway WS endpoint (configurable via appsettings)</item>
/// <item>Handles the v3 protocol handshake (challenge → connect → hello-ok)</item>
/// <item>Subscribes to <c>sessions.changed</c> and related events</item>
/// <item>Translates session state changes into <see cref="AgentStatusUpdate"/>
/// and <see cref="TaskProgressUpdate"/> objects</item>
/// <item>Pushes updates through the <see cref="Hubs.AgentStatusHub"/> SignalR hub</item>
/// </list>
///
/// <para>This is the server-side bridge that allows Angular clients to
/// receive real-time updates via SignalR instead of connecting directly
/// to the Gateway WebSocket.</para>
/// </summary>
public class GatewayEventBridgeService : BackgroundService
{
private readonly ILogger<GatewayEventBridgeService> _logger;
private readonly IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> _hubContext;
private readonly IConfiguration _configuration;
/// <summary>
/// In-memory fleet state — maps agent IDs to their latest card data.
/// Updated on every <c>sessions.changed</c> event from the Gateway.
/// </summary>
private readonly ConcurrentDictionary<string, AgentCardData> _fleetState = new();
/// <summary>
/// Known agent roles for display in the Command Hub.
/// Maps agent IDs to their functional descriptions.
/// </summary>
private static readonly Dictionary<string, string> AgentRoles = new()
{
["main"] = "Primary Assistant",
["otto"] = "Orchestrator Agent",
["dave"] = "Network Admin Agent",
["bob"] = "Content Writer Agent",
["stuart"] = "Image & Creative Agent",
["phil"] = "Home Automation Agent",
["carl"] = "Security Agent",
["larry"] = "Business Agent",
["mel"] = "E-Commerce Agent",
["norbert"] = "Product Agent",
["jerry"] = "Market Research Agent",
["rex"] = "Frontend Dev Agent",
["dex"] = "Backend Dev Agent",
["hex"] = "Database Agent",
["pip"] = "Raspberry Pi Agent",
["nano"] = "ESP32/Firmware Agent",
["axiom"] = "Utility Agent",
["bonnie"] = "Music Agent",
["sketch"] = "UI/UX Design Agent",
["flip"] = "Mobile Dev Agent",
["buzz"] = "SEO Agent",
["aries"] = "Companion Agent"
};
/// <summary>
/// Maps OpenClaw session status to <see cref="AgentStatus"/>.
/// </summary>
private static string MapSessionStatus(string? sessionStatus) => sessionStatus switch
{
"running" => "active",
"streaming" => "thinking",
"error" or "aborted" => "error",
"done" => "idle",
_ => "idle"
};
public GatewayEventBridgeService(
ILogger<GatewayEventBridgeService> logger,
IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> hubContext,
IConfiguration configuration)
{
_logger = logger;
_hubContext = hubContext;
_configuration = configuration;
}
/// <summary>
/// Returns the current fleet state snapshot.
/// Used by the hub's <c>GetFleetSnapshot</c> method and by the
/// <c>AgentsController</c> REST endpoint.
/// </summary>
public AgentCardData[] GetFleetSnapshot() =>
_fleetState.Values.ToArray();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Gateway Event Bridge service starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ConnectAndListenAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Gateway Event Bridge service stopping");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Gateway connection lost, reconnecting in 5 seconds...");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
/// <summary>
/// Connects to the OpenClaw Gateway WebSocket and processes events
/// until the connection is lost or cancellation is requested.
/// </summary>
private async Task ConnectAndListenAsync(CancellationToken stoppingToken)
{
var gatewayUrl = _configuration["Gateway:WebSocketUrl"]
?? "ws://localhost:3271/ws";
var authToken = _configuration["Gateway:AuthToken"] ?? string.Empty;
_logger.LogInformation("Connecting to Gateway at {Url}", gatewayUrl);
using var ws = new ClientWebSocket();
// Set auth header if available
if (!string.IsNullOrEmpty(authToken))
{
ws.Options.SetRequestHeader("Authorization", $"Bearer {authToken}");
}
await ws.ConnectAsync(new Uri(gatewayUrl), stoppingToken);
_logger.LogInformation("Connected to Gateway WebSocket");
// Start receiving messages
await ReceiveMessagesAsync(ws, stoppingToken);
}
/// <summary>
/// Receives and processes WebSocket messages from the Gateway.
/// Handles the v3 protocol handshake and dispatches events.
/// </summary>
private async Task ReceiveMessagesAsync(ClientWebSocket ws, CancellationToken stoppingToken)
{
var buffer = new byte[8192];
var messageBuilder = new StringBuilder();
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested)
{
WebSocketReceiveResult result;
try
{
result = await ws.ReceiveAsync(buffer, stoppingToken);
}
catch (WebSocketException ex)
{
_logger.LogWarning(ex, "WebSocket receive error");
break;
}
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogInformation("Gateway WebSocket closed by server");
break;
}
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
if (result.EndOfMessage)
{
var message = messageBuilder.ToString();
messageBuilder.Clear();
await ProcessMessageAsync(ws, message, stoppingToken);
}
}
}
/// <summary>
/// Processes a single WebSocket message from the Gateway.
/// Routes based on the message type: event, response, or challenge.
/// </summary>
private async Task ProcessMessageAsync(
ClientWebSocket ws, string message, CancellationToken stoppingToken)
{
try
{
using var doc = JsonDocument.Parse(message);
var root = doc.RootElement;
var type = root.GetProperty("type").GetString();
switch (type)
{
case "event":
await HandleGatewayEventAsync(root);
break;
case "res":
HandleGatewayResponse(root);
break;
}
// Special handling for connect.challenge events
if (root.TryGetProperty("event", out var eventName) &&
eventName.GetString() == "connect.challenge")
{
await HandleConnectChallengeAsync(ws, root, stoppingToken);
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse Gateway message: {Message}",
message.Length > 200 ? message[..200] + "..." : message);
}
}
/// <summary>
/// Handles the Gateway connect.challenge event by sending
/// a connect request with authentication credentials.
/// </summary>
private async Task HandleConnectChallengeAsync(
ClientWebSocket ws, JsonElement root, CancellationToken stoppingToken)
{
_logger.LogInformation("Received connect.challenge from Gateway");
var connectRequest = new
{
type = "req",
id = $"bridge-{Guid.NewGuid():N}",
method = "connect",
@params = new
{
minProtocol = 3,
maxProtocol = 3,
client = new
{
id = "control-center-backend",
version = "1.0.0",
platform = "server",
mode = "operator"
},
role = "operator",
scopes = new[] { "operator.read", "operator.write" },
auth = new
{
token = _configuration["Gateway:AuthToken"] ?? string.Empty
},
locale = "en-US",
userAgent = "control-center-backend/1.0.0"
}
};
var json = JsonSerializer.Serialize(connectRequest);
var bytes = Encoding.UTF8.GetBytes(json);
await ws.SendAsync(bytes, WebSocketMessageType.Text, true, stoppingToken);
}
/// <summary>
/// Handles a Gateway event message by dispatching to the
/// appropriate handler based on event name.
/// </summary>
private async Task HandleGatewayEventAsync(JsonElement root)
{
if (!root.TryGetProperty("event", out var eventProp)) return;
var eventName = eventProp.GetString();
_logger.LogDebug("Gateway event: {Event}", eventName);
switch (eventName)
{
case "sessions.changed":
await HandleSessionsChangedAsync(root);
break;
case "session.message":
HandleSessionMessage(root);
break;
case "session.tool":
HandleSessionTool(root);
break;
case "health":
HandleHealthEvent(root);
break;
}
}
/// <summary>
/// Handles a sessions.changed event from the Gateway.
/// Updates the fleet state and pushes status changes through SignalR.
/// </summary>
private async Task HandleSessionsChangedAsync(JsonElement root)
{
if (!root.TryGetProperty("payload", out var payload)) return;
// The payload may contain a snapshot of all sessions
if (payload.TryGetProperty("snapshot", out var snapshot) &&
snapshot.ValueKind == JsonValueKind.Array)
{
foreach (var session in snapshot.EnumerateArray())
{
var cardData = SessionToCardData(session);
if (cardData is null) continue;
_fleetState[cardData.Id] = cardData;
var update = new AgentStatusUpdate(
AgentId: cardData.Id,
DisplayName: cardData.DisplayName,
Role: cardData.Role,
Status: cardData.Status,
CurrentTask: cardData.CurrentTask,
SessionKey: cardData.SessionKey,
Channel: cardData.Channel,
LastActivity: cardData.LastActivity,
ErrorMessage: cardData.ErrorMessage
);
await _hubContext.PushAgentStatusAsync(update);
}
}
// Handle individual updates/added/removed
if (payload.TryGetProperty("updated", out var updated) &&
updated.ValueKind == JsonValueKind.Array)
{
foreach (var sessionKey in updated.EnumerateArray())
{
_logger.LogDebug("Session updated: {Key}", sessionKey.GetString());
}
}
}
/// <summary>
/// Handles a session.message event. Updates the agent's last activity
/// and pushes a status update if the status changed.
/// </summary>
private void HandleSessionMessage(JsonElement root)
{
if (!root.TryGetProperty("payload", out var payload)) return;
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
var agentId = ExtractAgentId(sessionKey);
if (string.IsNullOrEmpty(agentId)) return;
// Update last activity timestamp
if (_fleetState.TryGetValue(agentId, out var existing))
{
_fleetState[agentId] = existing with
{
LastActivity = DateTime.UtcNow.ToString("o"),
Status = "active"
};
}
}
/// <summary>
/// Handles a session.tool event. Extracts tool progress information
/// and pushes a <see cref="TaskProgressUpdate"/> through SignalR.
/// </summary>
private void HandleSessionTool(JsonElement root)
{
if (!root.TryGetProperty("payload", out var payload)) return;
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
var agentId = ExtractAgentId(sessionKey);
if (string.IsNullOrEmpty(agentId)) return;
var toolName = payload.TryGetProperty("toolName", out var tn) ? tn.GetString() : null;
var toolStatus = payload.TryGetProperty("status", out var ts) ? ts.GetString() : null;
if (toolName is null || toolStatus is null) return;
var progress = toolStatus switch
{
"started" => 0,
"completed" => 100,
_ => (int?)null
};
var update = new TaskProgressUpdate(
AgentId: agentId,
TaskDescription: $"{toolName} ({toolStatus})",
Progress: progress,
Elapsed: null
);
// Fire and forget — don't block the event loop
_ = _hubContext.PushTaskProgressAsync(update);
}
/// <summary>
/// Handles a health event from the Gateway.
/// Logs the health status for diagnostics.
/// </summary>
private void HandleHealthEvent(JsonElement root)
{
if (!root.TryGetProperty("payload", out var payload)) return;
var ok = payload.TryGetProperty("ok", out var okProp) && okProp.GetBoolean();
var status = payload.TryGetProperty("status", out var s) ? s.GetString() : "unknown";
_logger.LogInformation("Gateway health: ok={Ok}, status={Status}", ok, status);
}
/// <summary>
/// Handles a Gateway response message. Currently only logs for diagnostics.
/// </summary>
private void HandleGatewayResponse(JsonElement root)
{
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
{
_logger.LogDebug("Gateway RPC response OK");
// Check for hello-ok after connect
if (root.TryGetProperty("payload", out var payload) &&
payload.TryGetProperty("type", out var typeProp) &&
typeProp.GetString() == "hello-ok")
{
_logger.LogInformation("Gateway handshake complete (hello-ok received)");
}
}
else if (root.TryGetProperty("error", out var error))
{
var errorMsg = error.TryGetProperty("message", out var msg)
? msg.GetString() : "unknown error";
_logger.LogWarning("Gateway RPC error: {Error}", errorMsg);
}
}
/// <summary>
/// Converts a raw Gateway session JSON element into an
/// <see cref="AgentCardData"/> record.
/// </summary>
private AgentCardData? SessionToCardData(JsonElement session)
{
// Extract agent ID from session key or agentId field
string? agentId = null;
if (session.TryGetProperty("agentId", out var aid))
agentId = aid.GetString();
else if (session.TryGetProperty("key", out var key))
agentId = ExtractAgentId(key.GetString() ?? string.Empty);
if (string.IsNullOrEmpty(agentId)) return null;
var sessionKey = session.TryGetProperty("key", out var sk)
? sk.GetString() ?? string.Empty : string.Empty;
var status = session.TryGetProperty("status", out var s)
? MapSessionStatus(s.GetString()) : "idle";
var channel = ExtractChannel(session);
var lastActivity = session.TryGetProperty("updatedAt", out var ua)
? DateTimeOffset.FromUnixTimeMilliseconds(ua.GetInt64()).ToString("o")
: DateTime.UtcNow.ToString("o");
var displayName = char.ToUpperInvariant(agentId![0]) + agentId[1..];
var role = AgentRoles.GetValueOrDefault(agentId!, "Agent");
return new AgentCardData(
Id: agentId!,
DisplayName: displayName,
Role: role,
Status: status,
CurrentTask: null,
TaskProgress: null,
TaskElapsed: null,
SessionKey: sessionKey,
Channel: channel,
LastActivity: lastActivity,
ErrorMessage: status == "error" ? "Agent encountered an error" : null
);
}
/// <summary>
/// Extracts the agent ID from a session key.
/// Session key format: "agent:{agentId}:{channel}:..."
/// </summary>
private static string? ExtractAgentId(string sessionKey)
{
if (string.IsNullOrEmpty(sessionKey)) return null;
var parts = sessionKey.Split(':');
if (parts.Length >= 2 && parts[0] == "agent")
return parts[1];
return null;
}
/// <summary>
/// Extracts the channel from a session element.
/// </summary>
private static string ExtractChannel(JsonElement session)
{
// Try direct "channel" property
if (session.TryGetProperty("channel", out var ch))
return ch.GetString() ?? "unknown";
// Try origin.provider
if (session.TryGetProperty("origin", out var origin) &&
origin.TryGetProperty("provider", out var provider))
return provider.GetString() ?? "unknown";
return "unknown";
}
}

View File

@@ -1,19 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"ControlCenter": "Debug"
}
},
"Gateway": {
"WebSocketUrl": "ws://localhost:3271/ws",
"AuthToken": ""
},
"Cors": {
"AllowedOrigins": [
"http://localhost:4200",
"http://localhost:5000"
]
}
}

View File

@@ -1,22 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"ControlCenter": "Debug"
}
},
"AllowedHosts": "*",
"Gateway": {
"WebSocketUrl": "ws://localhost:3271/ws",
"AuthToken": ""
},
"Cors": {
"AllowedOrigins": [
"http://localhost:4200",
"http://localhost:5000"
]
}
}

View File

@@ -1,29 +0,0 @@
using ControlCenter.Api.Configurations;
using ControlCenter.Api.Entities;
using Microsoft.EntityFrameworkCore;
namespace ControlCenter.Api.Data;
/// <summary>
/// EF Core DbContext for the Control Center database.
/// All table and column names use snake_case via explicit HasColumnName configuration.
/// </summary>
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Agent> Agents => Set<Agent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply all entity type configurations from the Configurations namespace
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AgentConfiguration).Assembly);
// Map the AgentStatus enum to a PostgreSQL enum type named "agent_status"
// This must be called after ApplyConfigurations to ensure the model is built
// before the enum mapping is applied.
modelBuilder.HasPostgresEnum<AgentStatus>();
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore;
using ControlCenter.Api.Entities;
namespace ControlCenter.Api.Data;
/// <summary>
/// Design-time factory for AppDbContext, used by EF Core tools (dotnet ef)
/// to create migrations without requiring a running application.
/// </summary>
public class AppDbContextFactory : Microsoft.EntityFrameworkCore.Design.IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
// Connection string for design-time operations (migrations).
// In production, this comes from appsettings / environment variables.
var connectionString = "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
optionsBuilder.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
});
return new AppDbContext(optionsBuilder.Options);
}
}

View File

@@ -1,75 +0,0 @@
namespace ControlCenter.Api.Dtos;
/// <summary>
/// Data transfer object for broadcasting agent status updates
/// to all connected SignalR clients.
/// </summary>
public class AgentStatusUpdateDto
{
/// <summary>
/// Agent identifier, e.g. "otto", "dex", "rex".
/// Not null — every update must identify the agent it refers to.
/// </summary>
public string AgentId { get; set; } = string.Empty;
/// <summary>
/// Human-readable display name, e.g. "Otto", "Dex".
/// Not null — used by clients to render agent cards.
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
/// Not null — provides context for the agent's function.
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// Current operational status of the agent.
/// Maps to <see cref="Entities.AgentStatus"/> values as lowercase strings:
/// "active", "idle", "thinking", "error".
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Description of the agent's current task, if any.
/// Null when the agent is idle with no active task.
/// </summary>
public string? CurrentTask { get; set; }
/// <summary>
/// Task progress percentage (0100).
/// Null when progress is not trackable for the current task.
/// </summary>
public int? TaskProgress { get; set; }
/// <summary>
/// Elapsed time string for the current task, e.g. "04m 12s".
/// Null when no task is active.
/// </summary>
public string? TaskElapsed { get; set; }
/// <summary>
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
/// Not null — uniquely identifies the agent session.
/// </summary>
public string SessionKey { get; set; } = string.Empty;
/// <summary>
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
/// Not null — every agent session operates on exactly one channel.
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// ISO 8601 timestamp of the agent's last activity.
/// Not null — used by clients to detect stale connections.
/// </summary>
public string LastActivity { get; set; } = string.Empty;
/// <summary>
/// Error message when the agent status is "error".
/// Null when the agent is not in an error state.
/// </summary>
public string? ErrorMessage { get; set; }
}

View File

@@ -1,59 +0,0 @@
namespace ControlCenter.Api.Entities;
/// <summary>
/// Represents an agent's current state in the Control Center.
/// Each row tracks one agent session's status, task, and activity.
/// </summary>
public class Agent
{
/// <summary>
/// Primary key — UUID generated on insert.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Current operational status of the agent.
/// Stored as an enum in PostgreSQL via Npgsql.
/// </summary>
public AgentStatus Status { get; set; } = AgentStatus.Idle;
/// <summary>
/// Description of the agent's current task, if any.
/// Nullable — not all agents have an active task.
/// </summary>
public string? Task { get; set; }
/// <summary>
/// Task progress percentage (0100).
/// Nullable — progress is only meaningful when an agent has a trackable task.
/// </summary>
public int? Progress { get; set; }
/// <summary>
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
/// Not null — every agent row must be associated with a session.
/// </summary>
public string SessionKey { get; set; } = string.Empty;
/// <summary>
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
/// Not null — every agent session operates on exactly one channel.
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// Timestamp of the agent's last activity.
/// Default: current UTC timestamp on insert.
/// </summary>
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
/// <summary>
/// Row creation timestamp. Set automatically on insert.
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Row last-update timestamp. Updated automatically on any modification.
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -1,13 +0,0 @@
namespace ControlCenter.Api.Entities;
/// <summary>
/// Agent operational status enum.
/// Maps to the agent_status enum type in PostgreSQL.
/// </summary>
public enum AgentStatus
{
Active = 0,
Idle = 1,
Thinking = 2,
Error = 3
}

View File

@@ -1,155 +0,0 @@
using ControlCenter.Api.Dtos;
using Microsoft.AspNetCore.SignalR;
namespace ControlCenter.Api.Hubs;
/// <summary>
/// SignalR hub for broadcasting agent status updates to connected clients.
///
/// <para>
/// Clients call <see cref="SendStatusUpdate"/> to broadcast a status change,
/// and the hub relays it to all connected clients via the
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
/// </para>
///
/// <para>
/// Server-side code should use <see cref="AgentStatusHubExtensions.PushStatusUpdateAsync"/>
/// via <c>IHubContext&lt;AgentStatusHub, IAgentStatusClient&gt;</c> for background-service broadcasts.
/// </para>
///
/// <para>
/// Architecture note: This hub bridges OpenClaw Gateway events to SignalR clients.
/// A background service subscribes to Gateway events and pushes them through
/// this hub's extension methods.
/// </para>
/// </summary>
public class AgentStatusHub : Hub<IAgentStatusClient>
{
private readonly ILogger<AgentStatusHub> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AgentStatusHub"/> class.
/// </summary>
/// <param name="logger">Logger for diagnostic output.</param>
public AgentStatusHub(ILogger<AgentStatusHub> logger)
{
_logger = logger;
}
/// <summary>
/// Broadcasts an agent status update to all connected clients.
///
/// <para>
/// Any connected client (or server-side caller) can invoke this method
/// to push a status update to every subscriber. The update is relayed
/// through the <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
/// </para>
/// </summary>
/// <param name="update">The agent status update payload to broadcast.</param>
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
{
_logger.LogInformation(
"Broadcasting status update for agent {AgentId}: {Status}",
update.AgentId, update.Status);
await Clients.All.AgentStatusChanged(update);
}
/// <summary>
/// Adds the calling connection to the fleet group.
/// Once joined, the client will receive all agent status updates.
/// </summary>
public async Task JoinFleet()
{
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
}
/// <summary>
/// Removes the calling connection from the fleet group.
/// </summary>
public async Task LeaveFleet()
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
}
/// <summary>
/// Overrides <see cref="Hub{T}.OnDisconnectedAsync"/> to log disconnections.
/// SignalR automatically removes disconnected connections from all groups.
/// </summary>
/// <param name="exception">Exception that caused the disconnection, if any.</param>
public override Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
/// <summary>
/// The SignalR group name for the entire fleet (all agents).
/// </summary>
internal const string FleetGroupName = "fleet";
}
/// <summary>
/// Strongly-typed client interface for the AgentStatus SignalR hub.
/// Defines the methods the server can invoke on connected clients
/// to push real-time agent status updates.
/// </summary>
public interface IAgentStatusClient
{
/// <summary>
/// Pushes an agent status change to all subscribed clients.
/// Fired whenever an agent's operational status changes
/// (e.g., idle → active, active → thinking, active → error).
/// </summary>
/// <param name="update">The full status update payload.</param>
/// <returns>A Task that completes when the client has processed the update.</returns>
Task AgentStatusChanged(AgentStatusUpdateDto update);
}
/// <summary>
/// Extension methods for pushing real-time agent updates through
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
///
/// <para>
/// These methods are intended to be called from background services
/// or other server-side code that detects an agent state change,
/// using the injected <c>IHubContext&lt;AgentStatusHub, IAgentStatusClient&gt;</c>.
/// </para>
/// </summary>
public static class AgentStatusHubExtensions
{
/// <summary>
/// Pushes an agent status update to all connected clients.
///
/// <para>
/// Call this from any background service when an agent's
/// operational status changes (e.g., the Gateway reports a
/// session transition from "running" to "done").
/// </para>
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="update">The agent status update payload.</param>
/// <returns>A Task that completes when the message has been sent to all clients.</returns>
public static async Task PushStatusUpdateAsync(
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
AgentStatusUpdateDto update)
{
await hubContext.Clients.All.AgentStatusChanged(update);
}
/// <summary>
/// Pushes an agent status update to clients subscribed to the fleet group.
/// </summary>
/// <param name="hubContext">The hub context injected via DI.</param>
/// <param name="update">The agent status update payload.</param>
/// <returns>A Task that completes when the message has been sent to the fleet group.</returns>
public static async Task PushStatusUpdateToFleetAsync(
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
AgentStatusUpdateDto update)
{
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
.AgentStatusChanged(update);
}
}

View File

@@ -1,94 +0,0 @@
// <auto-generated />
using System;
using ControlCenter.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ControlCenter.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260426101703_CreateAgentsTable")]
partial class CreateAgentsTable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Channel")
.IsRequired()
.HasColumnType("text")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTime>("LastActivity")
.HasColumnType("timestamptz")
.HasColumnName("last_activity");
b.Property<int?>("Progress")
.HasColumnType("integer")
.HasColumnName("progress");
b.Property<string>("SessionKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("session_key");
b.Property<int>("Status")
.HasColumnType("agent_status")
.HasColumnName("status");
b.Property<string>("Task")
.HasColumnType("text")
.HasColumnName("task");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("Channel")
.HasDatabaseName("ix_agents_channel");
b.HasIndex("SessionKey")
.IsUnique()
.HasDatabaseName("ix_agents_session_key");
b.HasIndex("Status")
.HasDatabaseName("ix_agents_status");
b.ToTable("agents", null, t =>
{
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,61 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ControlCenter.Api.Migrations
{
/// <inheritdoc />
public partial class CreateAgentsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:agent_status", "active,idle,thinking,error");
migrationBuilder.CreateTable(
name: "agents",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
status = table.Column<int>(type: "agent_status", nullable: false),
task = table.Column<string>(type: "text", nullable: true),
progress = table.Column<int>(type: "integer", nullable: true),
session_key = table.Column<string>(type: "text", nullable: false),
channel = table.Column<string>(type: "text", nullable: false),
last_activity = table.Column<DateTime>(type: "timestamptz", nullable: false),
created_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_agents", x => x.id);
table.CheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
});
migrationBuilder.CreateIndex(
name: "ix_agents_channel",
table: "agents",
column: "channel");
migrationBuilder.CreateIndex(
name: "ix_agents_session_key",
table: "agents",
column: "session_key",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_agents_status",
table: "agents",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "agents");
}
}
}

View File

@@ -1,91 +0,0 @@
// <auto-generated />
using System;
using ControlCenter.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ControlCenter.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Channel")
.IsRequired()
.HasColumnType("text")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTime>("LastActivity")
.HasColumnType("timestamptz")
.HasColumnName("last_activity");
b.Property<int?>("Progress")
.HasColumnType("integer")
.HasColumnName("progress");
b.Property<string>("SessionKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("session_key");
b.Property<int>("Status")
.HasColumnType("agent_status")
.HasColumnName("status");
b.Property<string>("Task")
.HasColumnType("text")
.HasColumnName("task");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("Channel")
.HasDatabaseName("ix_agents_channel");
b.HasIndex("SessionKey")
.IsUnique()
.HasDatabaseName("ix_agents_session_key");
b.HasIndex("Status")
.HasDatabaseName("ix_agents_status");
b.ToTable("agents", null, t =>
{
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,19 +0,0 @@
namespace ControlCenter.Api.Models;
/// <summary>
/// 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.
/// </summary>
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; }
}

View File

@@ -1,42 +0,0 @@
using ControlCenter.Api.Data;
using ControlCenter.Api.Hubs;
using ControlCenter.Api.Repositories;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddOpenApi();
// Register SignalR for real-time agent status updates
builder.Services.AddSignalR();
// Register Agent State Repository
builder.Services.AddScoped<IAgentStateRepository, AgentStateRepository>();
// Register DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Map SignalR hubs
app.MapHub<AgentStatusHub>("/hubs/agent-status");
app.Run();

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5178",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7041;http://localhost:5178",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,76 +0,0 @@
using ControlCenter.Api.Data;
using ControlCenter.Api.Entities;
using ControlCenter.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace ControlCenter.Api.Repositories;
/// <summary>
/// EF Core implementation of the Agent State Repository.
/// Maps between the persisted <see cref="Agent"/> entity and the
/// read-oriented <see cref="AgentState"/> model.
/// </summary>
public class AgentStateRepository : IAgentStateRepository
{
private readonly AppDbContext _db;
public AgentStateRepository(AppDbContext db)
{
_db = db;
}
/// <inheritdoc />
public async Task<IReadOnlyList<AgentState>> GetAllAsync(CancellationToken ct = default)
{
var agents = await _db.Agents
.AsNoTracking()
.OrderByDescending(a => a.LastActivity)
.ToListAsync(ct);
return agents.Select(ToModel).ToList();
}
/// <inheritdoc />
public async Task<AgentState?> 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);
}
/// <inheritdoc />
public async Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
{
if (!Enum.TryParse<AgentStatus>(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;
}
/// <summary>
/// Maps a persisted <see cref="Agent"/> entity to a <see cref="AgentState"/> model.
/// </summary>
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,
};
}

View File

@@ -1,27 +0,0 @@
using ControlCenter.Api.Models;
namespace ControlCenter.Api.Repositories;
/// <summary>
/// Repository interface for accessing and mutating Agent State.
/// Provides a clean abstraction over the EF Core data access layer.
/// </summary>
public interface IAgentStateRepository
{
/// <summary>
/// Retrieve all agent states.
/// </summary>
Task<IReadOnlyList<AgentState>> GetAllAsync(CancellationToken ct = default);
/// <summary>
/// Retrieve a single agent state by its session key.
/// Returns null if no agent is found with the given session key.
/// </summary>
Task<AgentState?> GetBySessionKeyAsync(string sessionKey, CancellationToken ct = default);
/// <summary>
/// Update the status of an agent by its primary key.
/// Returns true if the agent was found and updated, false otherwise.
/// </summary>
Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,12 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=control_center;Username=postgres;Password=postgres"
}
}

88
docker-compose.yml Normal file
View File

@@ -0,0 +1,88 @@
# =============================================================================
# Control Center — Full-Stack Docker Compose
# =============================================================================
# Boot the entire stack: PostgreSQL + Go backend + React frontend.
#
# Usage:
# cp .env.example .env # fill in values
# docker compose up --build # first run (builds images)
# docker compose up # subsequent runs
# docker compose down -v # teardown including DB volume
# =============================================================================
name: control-center
services:
# ── PostgreSQL ──────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: cc-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-controlcenter}
POSTGRES_USER: ${POSTGRES_USER:-controlcenter}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./go-backend/migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-controlcenter} -d ${POSTGRES_DB:-controlcenter}"]
interval: 5s
timeout: 3s
retries: 5
networks:
- cc-network
# ── Go Backend ──────────────────────────────────────────────────────────────
backend:
build:
context: ./go-backend
dockerfile: Dockerfile
container_name: cc-backend
restart: unless-stopped
ports:
- "${BACKEND_PORT:-8080}:8080"
environment:
PORT: "8080"
DATABASE_URL: postgres://${POSTGRES_USER:-controlcenter}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-controlcenter}?sslmode=disable
CORS_ORIGIN: "${CORS_ORIGIN:-http://localhost:3000}"
LOG_LEVEL: ${LOG_LEVEL:-info}
ENVIRONMENT: ${ENVIRONMENT:-development}
GATEWAY_URL: ${GATEWAY_URL:-http://openclaw-gateway:18789/api/agents}
GATEWAY_POLL_INTERVAL: ${GATEWAY_POLL_INTERVAL:-5s}
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
networks:
- cc-network
# ── React Frontend (nginx) ──────────────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: cc-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3000}:80"
depends_on:
backend:
condition: service_healthy
networks:
- cc-network
volumes:
postgres_data:
driver: local
networks:
cc-network:
driver: bridge

View File

@@ -1,17 +0,0 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

58
frontend/.gitignore vendored
View File

@@ -1,44 +1,24 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
node_modules
dist
dist-ssr
*.local
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.idea
.DS_Store
Thumbs.db
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,12 +0,0 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -1,20 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -1,9 +0,0 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

View File

@@ -1,42 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

38
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# ============================================================
# Control Center Frontend — Multi-stage Docker Build
# React 19 + Vite + nginx for static serving + API proxy
# ============================================================
# --- Build Stage ---
FROM node:22-slim AS builder
WORKDIR /app
# Install dependencies first (layer caching)
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build production bundle
COPY . .
RUN npm run build
# --- Runtime Stage ---
FROM nginx:1.27-alpine AS runtime
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built React app from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose HTTP port
EXPOSE 80
# Health check — confirm nginx is serving
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,59 +1,73 @@
# Frontend
# React + TypeScript + Vite
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Development server
Currently, two official plugins are available:
To start a local development server, run:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
```bash
ng serve
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
## Code scaffolding
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -1,102 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"stylePreprocessorOptions": {
"includePaths": [
"src"
]
}
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}

22
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,41 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Cache static assets (Vite uses content hashes)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache hashed JS/CSS bundles
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# React SPA — all other routes fall back to index.html
location / {
try_files $uri $uri/ /index.html;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,38 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.11.0",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@angular/animations": "^21.2.10",
"@angular/cdk": "^21.2.8",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.8",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@microsoft/signalr": "^10.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
"@tanstack/react-query": "^5.100.9",
"axios": "^1.16.0",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0"
},
"devDependencies": {
"@angular/build": "^21.2.8",
"@angular/cli": "^21.2.8",
"@angular/compiler-cli": "^21.2.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.2.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

23
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import HubPage from './pages/HubPage'
import LogsPage from './pages/LogsPage'
import ProjectsPage from './pages/ProjectsPage'
import SessionsPage from './pages/SessionsPage'
import SettingsPage from './pages/SettingsPage'
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<HubPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Layout>
)
}
export default App

View File

@@ -1,13 +0,0 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideAnimationsAsync(),
],
};

View File

@@ -1,344 +0,0 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: block;
height: 100dvh;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://x.com/angular"
aria-label="X"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="X"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />

View File

@@ -1,22 +0,0 @@
import { Routes } from '@angular/router';
import { LayoutShellComponent } from './layout/layout-shell/layout-shell.component';
import { HubPageComponent } from './pages/hub/hub-page.component';
import { ProjectsPageComponent } from './pages/projects/projects-page.component';
import { SessionsPageComponent } from './pages/sessions/sessions-page.component';
import { LogsPageComponent } from './pages/logs/logs-page.component';
import { SettingsPageComponent } from './pages/settings/settings-page.component';
export const routes: Routes = [
{
path: '',
component: LayoutShellComponent,
children: [
{ path: '', redirectTo: 'hub', pathMatch: 'full' },
{ path: 'hub', component: HubPageComponent },
{ path: 'projects', component: ProjectsPageComponent },
{ path: 'sessions', component: SessionsPageComponent },
{ path: 'logs', component: LogsPageComponent },
{ path: 'settings', component: SettingsPageComponent },
],
},
];

View File

@@ -1,4 +0,0 @@
:host {
display: block;
min-height: 100vh;
}

View File

@@ -1,16 +0,0 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
styles: [`
:host {
display: block;
min-height: 100vh;
}
`],
})
export class App {}

View File

@@ -1,82 +0,0 @@
<!-- ========================================================================== -->
<!-- AgentCard — per spec Section 7.3 -->
<!-- Integrates: Status Badge · Task Progress Bar · QuickJump Button -->
<!-- Leftborder accent matches status color. role="article" + arialabels. -->
<!-- ========================================================================== -->
<article
class="agent-card"
role="article"
[attr.aria-label]="displayName + ' ' + statusLabel()"
[style.border-left-color]="statusBorderColor()"
>
<!-- ── Header: status badge + agent info ── -->
<div class="agent-card__header">
<div class="agent-card__badge" [attr.aria-label]="'Status: ' + statusLabel()">
<span
class="status-dot"
[ngClass]="[statusDotClass()]"
></span>
<span class="agent-card__status-label">{{ statusLabel() }}</span>
</div>
<div class="agent-card__identity">
<span class="agent-card__name">{{ displayName || agentId }}</span>
<span class="agent-card__role">{{ role }}</span>
</div>
</div>
<!-- ── Body: current task ── -->
<div class="agent-card__body" *ngIf="task || status === 'error'">
<p
class="agent-card__task"
[attr.aria-label]="'Current task: ' + (status === 'error' ? errorMessage : task)"
>
{{ status === 'error' ? errorMessage || task : task }}
</p>
</div>
<!-- ── Task Progress Bar ── -->
<div
class="agent-card__progress"
*ngIf="progress > 0 && status !== 'error'"
[attr.aria-label]="'Task progress: ' + progress + '%'"
>
<mat-progress-bar
mode="determinate"
[value]="progress"
[aria-label]="'Progress ' + progress + '% complete'"
></mat-progress-bar>
<span class="agent-card__progress-label text-mono">{{ progress }}%</span>
</div>
<!-- ── Footer: channel + last activity + quickjump ── -->
<div class="agent-card__footer">
<div class="agent-card__meta">
<span
class="agent-card__channel text-mono"
[attr.aria-label]="'Channel: ' + channel"
>
<mat-icon aria-hidden="true">{{ channelIcon() }}</mat-icon>
{{ channel }}
</span>
<span
class="agent-card__last-activity text-mono"
[attr.aria-label]="'Last activity: ' + lastActivityLabel()"
>
{{ lastActivityLabel() }}
</span>
</div>
<!-- QuickJump Button -->
<a
class="agent-card__jump"
mat-button
[routerLink]="jumpRoute()"
[attr.aria-label]="'Jump to session for ' + (displayName || agentId)"
matTooltip="Jump to session"
>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
</a>
</div>
</article>

View File

@@ -1,234 +0,0 @@
// ============================================================================
// AgentCard — M3 tactical dark styling
// Per spec Section 7.3: leftborder accent, statusaware coloring,
// responsive card layout with 320px minwidth.
// ============================================================================
.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);
// Perstatus 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);
}
// ── QuickJump 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;
}
}
// ── Statusspecific 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;
}
}

View File

@@ -1,127 +0,0 @@
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 QuickJump Button into a card with leftborder 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 leftborder accent */
@Input({ required: true }) status!: AgentStatus;
/** Current task description, e.g. "Reviewing PR #42" */
@Input() task = '';
/** Task progress percentage 0100 */
@Input() progress = 0;
/** Full session key for quickjump 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 leftborder accent */
readonly statusBorderColor = computed(() => {
const map: Record<AgentStatus, string> = {
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)';
});
/** Humanreadable status label */
readonly statusLabel = computed(() => {
const labels: Record<AgentStatus, string> = {
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<string, string> = {
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`;
});
/** Quickjump route derived from sessionKey */
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
}

View File

@@ -1 +0,0 @@
export * from './agent-card/agent-card.component';

View File

@@ -1,112 +0,0 @@
<!-- ======================================================================== -->
<!-- Adaptive Navigation — Desktop sidebar / Mobile header -->
<!-- Desktop (≥768px): 72px sidebar with full navigation items -->
<!-- Mobile (<768px): 56px compact header with hamburger menu -->
<!-- ======================================================================== -->
<!-- ======================= DESKTOP SIDEBAR (≥768px) ======================= -->
<aside class="adaptive-nav__sidebar" aria-label="Navigation sidebar">
<!-- Brand / Toggle header -->
<div class="adaptive-nav__sidebar-header">
<span class="adaptive-nav__brand">OC</span>
</div>
<!-- Navigation destinations -->
<nav class="adaptive-nav__sidebar-nav">
@for (dest of destinations; track dest.route) {
<a
class="adaptive-nav__sidebar-item"
[routerLink]="dest.route"
routerLinkActive="adaptive-nav__sidebar-item--active"
[attr.aria-label]="dest.label"
>
<mat-icon
[matBadge]="dest.badge ?? 0"
[matBadgeHidden]="!dest.badge"
matBadgePosition="above after"
matBadgeSize="small"
>
{{ dest.icon }}
</mat-icon>
<span class="adaptive-nav__sidebar-label">{{ dest.label }}</span>
</a>
}
</nav>
<!-- Sidebar footer: LIVE indicator + action buttons -->
<div class="adaptive-nav__sidebar-footer">
<div class="adaptive-nav__live" [class.adaptive-nav__live--connected]="isConnected()">
<span
class="adaptive-nav__live-dot"
[class.adaptive-nav__live-dot--connected]="isConnected()"
></span>
<mat-chip
class="adaptive-nav__live-chip"
[highlighted]="isConnected()"
[disabled]="!isConnected()"
>
{{ isConnected() ? 'LIVE' : 'OFFLINE' }}
</mat-chip>
</div>
<!-- Action buttons (placeholder) -->
<div class="adaptive-nav__sidebar-actions">
<button mat-icon-button aria-label="Notifications">
<mat-icon>notifications</mat-icon>
</button>
<button mat-icon-button aria-label="Settings">
<mat-icon>settings</mat-icon>
</button>
</div>
</div>
</aside>
<!-- ======================= MOBILE HEADER (<768px) ======================== -->
<header class="adaptive-nav__mobile-header" role="banner">
<!-- Hamburger menu button -->
<button
class="adaptive-nav__hamburger"
mat-icon-button
[attr.aria-label]="mobileMenuOpen() ? 'Close menu' : 'Open menu'"
[attr.aria-expanded]="mobileMenuOpen()"
(click)="toggleMobileMenu()"
>
<mat-icon>{{ mobileMenuOpen() ? 'close' : 'menu' }}</mat-icon>
</button>
<!-- Title -->
<h1 class="adaptive-nav__mobile-title">Command Hub</h1>
<!-- LIVE indicator (always visible on mobile) -->
<div class="adaptive-nav__live adaptive-nav__live--mobile" [class.adaptive-nav__live--connected]="isConnected()">
<span
class="adaptive-nav__live-dot"
[class.adaptive-nav__live-dot--connected]="isConnected()"
></span>
<span class="adaptive-nav__live-text">{{ isConnected() ? 'LIVE' : 'OFFLINE' }}</span>
</div>
<!-- Mobile action buttons (placeholder) -->
<button class="adaptive-nav__mobile-action" mat-icon-button aria-label="Notifications">
<mat-icon>notifications</mat-icon>
</button>
</header>
<!-- ======================= MOBILE DRAWER OVERLAY ========================= -->
@if (mobileMenuOpen()) {
<div class="adaptive-nav__overlay" (click)="closeMobileMenu()" role="presentation"></div>
<nav class="adaptive-nav__mobile-drawer" aria-label="Mobile navigation menu">
@for (dest of destinations; track dest.route) {
<a
class="adaptive-nav__drawer-item"
[routerLink]="dest.route"
routerLinkActive="adaptive-nav__drawer-item--active"
[attr.aria-label]="dest.label"
(click)="closeMobileMenu()"
>
<mat-icon>{{ dest.icon }}</mat-icon>
<span class="adaptive-nav__drawer-label">{{ dest.label }}</span>
</a>
}
</nav>
}

View File

@@ -1,316 +0,0 @@
// ============================================================================
// 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;
}
}

View File

@@ -1,53 +0,0 @@
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);
}
}

View File

@@ -1 +0,0 @@
export * from './adaptive-navigation.component';

View File

@@ -1,8 +0,0 @@
<span class="badge"
[class]="statusClass"
[class.badge--pulse]="hasPulse"
[attr.aria-label]="'Agent status: ' + displayLabel"
role="status">
<span class="badge__dot"></span>
<span class="badge__label">{{ displayLabel }}</span>
</span>

View File

@@ -1,146 +0,0 @@
// ============================================================================
// 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);
}
}

View File

@@ -1,54 +0,0 @@
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<AgentStatus>();
/** Label text shown inside the badge. Defaults to title-cased status. */
readonly label = input<string>();
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);
}
}

View File

@@ -1 +0,0 @@
export { AgentStatusBadgeComponent } from './agent-status-badge.component';

View File

@@ -1,31 +0,0 @@
<!-- Backdrop overlay — click to dismiss -->
<div class="global-action-modal__backdrop" #backdrop (click)="onBackdropClick()"></div>
<!-- Modal panel -->
<div class="global-action-modal__panel" (click)="onModalClick($event)" role="dialog" aria-modal="true" aria-label="Global Actions">
<!-- Header -->
<div class="global-action-modal__header">
<h2 class="global-action-modal__title">Global Actions</h2>
<button matIconButton
class="global-action-modal__close"
aria-label="Close modal"
(click)="onClose()">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Action grid -->
<div class="global-action-modal__actions">
@for (action of actions; track action.key) {
<button class="global-action-modal__action-btn global-action-modal__action-btn--{{ action.color }}"
(click)="onAction(action)">
<div class="global-action-modal__action-icon">
<mat-icon>{{ action.icon }}</mat-icon>
</div>
<span class="global-action-modal__action-label">{{ action.label }}</span>
<span class="global-action-modal__action-desc">{{ action.description }}</span>
</button>
}
</div>
</div>

View File

@@ -1,198 +0,0 @@
// ============================================================================
// 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;
}
}

View File

@@ -1,87 +0,0 @@
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<GlobalAction>();
/** Emitted when the modal is dismissed (backdrop click or close button). */
@Output() readonly dismissed = new EventEmitter<void>();
@ViewChild('backdrop') backdropEl!: ElementRef<HTMLElement>;
/** 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';
}

View File

@@ -1,3 +0,0 @@
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';

View File

@@ -1,8 +0,0 @@
<button
mat-icon-button
class="quick-jump-button"
[attr.aria-label]="'Jump to agent session'"
(click)="onJumpClick()"
>
<mat-icon>arrow_forward</mat-icon>
</button>

View File

@@ -1,68 +0,0 @@
// ============================================================================
// 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);
}
}

View File

@@ -1,32 +0,0 @@
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<string>();
/** The session key to navigate to. Set by the parent agent card. */
sessionKey = '';
onJumpClick(): void {
this.jumpClick.emit(this.sessionKey);
}
}

View File

@@ -1 +0,0 @@
export { QuickJumpDrawerComponent } from './quick-jump-drawer.component';

View File

@@ -1,109 +0,0 @@
<!-- ============================================================================
Quick-Jump Drawer — Slide-out panel for fast agent switching
Per CUB-51: Slides from right, agent list with status badges,
search/filter input, closes via ESC or outside click.
============================================================================-->
<!-- Backdrop overlay -->
@if (isOpen()) {
<div
class="quick-jump-backdrop"
(click)="onBackdropClick($event)"
[@backdropEnter]
></div>
}
<!-- Drawer panel -->
<div
class="quick-jump-drawer"
[class.quick-jump-drawer--open]="isOpen()"
(keydown)="onDrawerKeydown($event)"
role="dialog"
aria-label="Quick jump to agent"
[attr.aria-hidden]="!isOpen()"
>
<!-- Drawer header -->
<div class="quick-jump-drawer__header">
<h2 class="quick-jump-drawer__title">Jump to Agent</h2>
<button
class="quick-jump-drawer__close-btn"
type="button"
aria-label="Close drawer"
(click)="close()"
>
</button>
</div>
<!-- Search input -->
<div class="quick-jump-drawer__search">
<span class="quick-jump-drawer__search-icon">search</span>
<input
#searchInput
type="text"
class="quick-jump-drawer__search-input"
placeholder="Search agents..."
[formControl]="searchControl"
autocomplete="off"
aria-label="Search agents"
/>
@if (searchControl.value) {
<button
class="quick-jump-drawer__search-clear"
type="button"
aria-label="Clear search"
(click)="searchControl.setValue('')"
>
</button>
}
</div>
<!-- Agent list -->
<ul class="quick-jump-drawer__agent-list" role="listbox" aria-label="Agent list">
@for (agent of filteredAgents(); track agent.id; let i = $index) {
<li
[id]="'quick-jump-agent-' + i"
class="quick-jump-drawer__agent-item"
[class.quick-jump-drawer__agent-item--highlighted]="highlightedIndex() === i"
role="option"
[attr.aria-selected]="highlightedIndex() === i"
(click)="selectAgent(agent)"
(mouseenter)="highlightedIndex.set(i)"
(mouseleave)="highlightedIndex.set(-1)"
>
<!-- Status badge -->
<span
class="status-dot {{ getStatusClass(agent.status) }}"
[attr.aria-label]="getStatusLabel(agent.status)"
></span>
<!-- Agent info -->
<div class="quick-jump-drawer__agent-info">
<span class="quick-jump-drawer__agent-name">{{ agent.displayName }}</span>
<span class="quick-jump-drawer__agent-role">{{ agent.role }}</span>
</div>
<!-- Status label -->
<span class="quick-jump-drawer__agent-status-label" [class]="'status-label--' + agent.status">
{{ getStatusLabel(agent.status) }}
</span>
</li>
} @empty {
<li class="quick-jump-drawer__empty">
@if (searchControl.value) {
<span>No agents matching "{{ searchControl.value }}"</span>
} @else {
<span>No agents online</span>
}
</li>
}
</ul>
<!-- Footer hint -->
<div class="quick-jump-drawer__footer">
<span class="quick-jump-drawer__footer-hint">
<kbd>↑↓</kbd> Navigate &nbsp; <kbd></kbd> Select &nbsp; <kbd>Esc</kbd> Close
</span>
</div>
</div>

View File

@@ -1,333 +0,0 @@
// ============================================================================
// Quick-Jump Drawer — Slide-out panel for fast agent switching
// Per CUB-51: slides from right, agent list with status badges,
// search/filter input, closes via ESC or outside click.
// ============================================================================
// ---------------------------------------------------------------------------
// Backdrop
// ---------------------------------------------------------------------------
.quick-jump-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
opacity: 0;
transition: opacity 200ms ease-out;
&.backdrop-visible {
opacity: 1;
}
}
// ---------------------------------------------------------------------------
// Drawer Panel
// ---------------------------------------------------------------------------
.quick-jump-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 380px;
max-width: 90vw;
background-color: var(--cc-surface-container);
border-left: 1px solid var(--cc-outline);
z-index: 999;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
&--open {
transform: translateX(0);
}
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
.quick-jump-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 12px;
border-bottom: 1px solid var(--cc-outline);
}
.quick-jump-drawer__title {
font-size: 20px;
font-weight: 500;
color: var(--cc-on-surface);
margin: 0;
letter-spacing: -0.01em;
}
.quick-jump-drawer__close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--cc-on-surface-variant);
font-size: 18px;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
&:hover {
background-color: var(--cc-surface-container-high);
color: var(--cc-on-surface);
}
&:focus-visible {
outline: 2px solid var(--status-active);
outline-offset: 2px;
}
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
.quick-jump-drawer__search {
position: relative;
display: flex;
align-items: center;
margin: 16px 24px 8px;
border: 1px solid var(--cc-outline);
border-radius: 12px;
background-color: var(--cc-surface-container-high);
transition: border-color 150ms ease;
&:focus-within {
border-color: var(--status-active);
}
}
.quick-jump-drawer__search-icon {
display: flex;
align-items: center;
justify-content: center;
padding-left: 12px;
font-family: 'Material Icons';
font-size: 20px;
color: var(--cc-on-surface-variant);
pointer-events: none;
user-select: none;
// Use a simple "search" text since icon font may not be loaded inside
// the drawer — rely on Material icon font from the parent app
&::before {
content: 'search';
font-family: 'Material Icons';
}
}
.quick-jump-drawer__search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
padding: 12px 8px;
font-size: 15px;
font-family: 'Inter', 'Roboto', sans-serif;
color: var(--cc-on-surface);
&::placeholder {
color: var(--cc-on-surface-variant);
opacity: 0.7;
}
}
.quick-jump-drawer__search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 4px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--cc-on-surface-variant);
font-size: 14px;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
&:hover {
background-color: var(--cc-surface-container);
color: var(--cc-on-surface);
}
&:focus-visible {
outline: 2px solid var(--status-active);
outline-offset: 2px;
}
}
// ---------------------------------------------------------------------------
// Agent List
// ---------------------------------------------------------------------------
.quick-jump-drawer__agent-list {
list-style: none;
margin: 0;
padding: 8px 12px;
overflow-y: auto;
flex: 1;
}
.quick-jump-drawer__agent-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
cursor: pointer;
transition: background-color 150ms ease;
&:hover,
&--highlighted {
background-color: var(--cc-surface-container-high);
}
&--highlighted {
outline: 2px solid var(--status-active);
outline-offset: -2px;
}
&:focus-visible {
outline: 2px solid var(--status-active);
outline-offset: 2px;
}
}
.quick-jump-drawer__agent-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0; // Allow text truncation
flex: 1;
}
.quick-jump-drawer__agent-name {
font-size: 15px;
font-weight: 500;
color: var(--cc-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-jump-drawer__agent-role {
font-size: 12px;
color: var(--cc-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-jump-drawer__agent-status-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 6px;
white-space: nowrap;
&.status-label--active {
color: var(--status-active);
background-color: var(--status-active-bg);
}
&.status-label--idle {
color: var(--status-idle);
background-color: var(--status-idle-bg);
}
&.status-label--thinking {
color: var(--status-thinking);
background-color: var(--status-thinking-bg);
}
&.status-label--error {
color: var(--status-error);
background-color: var(--status-error-bg);
}
&.status-label--offline {
color: var(--status-offline);
background-color: rgba(100, 116, 139, 0.12);
}
}
// ---------------------------------------------------------------------------
// Empty State
// ---------------------------------------------------------------------------
.quick-jump-drawer__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--cc-on-surface-variant);
font-size: 14px;
text-align: center;
}
// ---------------------------------------------------------------------------
// Footer
// ---------------------------------------------------------------------------
.quick-jump-drawer__footer {
padding: 12px 24px 16px;
border-top: 1px solid var(--cc-outline);
}
.quick-jump-drawer__footer-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 11px;
color: var(--cc-on-surface-variant);
opacity: 0.7;
kbd {
display: inline-block;
padding: 2px 6px;
font-size: 11px;
font-family: var(--cc-font-mono);
background-color: var(--cc-surface-container-high);
border: 1px solid var(--cc-outline);
border-radius: 4px;
color: var(--cc-on-surface-variant);
line-height: 1.4;
}
}
// ---------------------------------------------------------------------------
// Mobile Adjustments
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.quick-jump-drawer {
width: 100%;
max-width: 100vw;
}
.quick-jump-drawer__header {
padding: 16px 16px 10px;
}
.quick-jump-drawer__search {
margin: 12px 16px 8px;
}
.quick-jump-drawer__agent-list {
padding: 4px 8px;
}
.quick-jump-drawer__footer {
padding: 10px 16px 14px;
}
}

View File

@@ -1,215 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
HostListener,
OnDestroy,
Output,
signal,
ViewChild,
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { AgentCardData } from '../../models/agent.model';
import { AgentStatusService } from '../../services/agent-status.service';
@Component({
selector: 'app-quick-jump-drawer',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './quick-jump-drawer.component.html',
styleUrl: './quick-jump-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuickJumpDrawerComponent implements OnDestroy {
/** Emits when the drawer should close (ESC, outside click, or item select). */
@Output() readonly drawerClose = new EventEmitter<void>();
/** Whether the drawer is visible. */
readonly isOpen = signal(false);
/** Search/filter input control. */
readonly searchControl = new FormControl('', { nonNullable: true });
/** Filtered agent list based on search. */
readonly filteredAgents = signal<AgentCardData[]>([]);
/** Track which agent row is highlighted via keyboard navigation. */
readonly highlightedIndex = signal(-1);
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
private readonly destroy$ = new Subject<void>();
constructor(private readonly agentStatusService: AgentStatusService) {
// Reactively filter agents as the search input changes
this.searchControl.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((query) => this.filterAgents(query));
// Initial load
this.filterAgents('');
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Open the drawer and focus the search input. */
open(): void {
this.isOpen.set(true);
this.searchControl.setValue('', { emitEvent: false });
this.highlightedIndex.set(-1);
// Focus search input after animation frame (drawer needs to render first)
requestAnimationFrame(() => {
this.searchInput?.nativeElement?.focus();
});
}
/** Close the drawer. */
close(): void {
this.isOpen.set(false);
this.searchControl.setValue('', { emitEvent: false });
this.highlightedIndex.set(-1);
this.drawerClose.emit();
}
/** Toggle the drawer open/close. */
toggle(): void {
if (this.isOpen()) {
this.close();
} else {
this.open();
}
}
// ---------------------------------------------------------------------------
// Keyboard Handling
// ---------------------------------------------------------------------------
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.isOpen()) {
this.close();
}
}
/** Handle keyboard navigation within the drawer panel. */
onDrawerKeydown(event: KeyboardEvent): void {
const agents = this.filteredAgents();
if (!agents.length) return;
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
this.highlightedIndex.update((i) =>
i < agents.length - 1 ? i + 1 : 0
);
this.scrollIntoView();
break;
}
case 'ArrowUp': {
event.preventDefault();
this.highlightedIndex.update((i) =>
i > 0 ? i - 1 : agents.length - 1
);
this.scrollIntoView();
break;
}
case 'Enter': {
const idx = this.highlightedIndex();
if (idx >= 0 && idx < agents.length) {
this.selectAgent(agents[idx]);
}
break;
}
}
}
// ---------------------------------------------------------------------------
// Outside Click
// ---------------------------------------------------------------------------
/** Close when clicking on the backdrop (outside the panel). */
onBackdropClick(event: MouseEvent): void {
if (
this.drawerPanel?.nativeElement &&
!this.drawerPanel.nativeElement.contains(event.target as Node)
) {
this.close();
}
}
// ---------------------------------------------------------------------------
// Agent Selection
// ---------------------------------------------------------------------------
/** Select an agent — navigates or focuses the agent card. */
selectAgent(agent: AgentCardData): void {
// TODO: Wire up navigation to the selected agent's detail view
// For now, emit close after selection
console.log('[QuickJump] Selected agent:', agent.id);
this.close();
}
// ---------------------------------------------------------------------------
// Status Helpers
// ---------------------------------------------------------------------------
/** Get the CSS class for a given agent status. */
getStatusClass(status: string): string {
return `status-dot--${status}`;
}
/** Get a human-readable label for an agent status. */
getStatusLabel(status: string): string {
const labels: Record<string, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking',
error: 'Error',
offline: 'Offline',
};
return labels[status] ?? status;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private filterAgents(query: string): void {
const allAgents = this.agentStatusService.agents();
const lowerQuery = query.toLowerCase().trim();
if (!lowerQuery) {
this.filteredAgents.set(allAgents);
return;
}
const filtered = allAgents.filter(
(agent) =>
agent.displayName.toLowerCase().includes(lowerQuery) ||
agent.id.toLowerCase().includes(lowerQuery) ||
agent.role.toLowerCase().includes(lowerQuery)
);
this.filteredAgents.set(filtered);
this.highlightedIndex.set(-1);
}
private scrollIntoView(): void {
const idx = this.highlightedIndex();
const el = document.getElementById(`quick-jump-agent-${idx}`);
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}

View File

@@ -1,6 +0,0 @@
// ============================================================================
// Task Progress Bar — Barrel Export
// CUB-44
// ============================================================================
export { TaskProgressBarComponent } from './task-progress-bar.component';

View File

@@ -1,18 +0,0 @@
<!-- Task Progress Bar: determinate progress with optional elapsed time -->
<div class="task-progress-bar">
<!-- Info row: percentage + optional elapsed -->
<div class="task-progress-bar__info">
<span class="task-progress-bar__percent">{{ clampedProgress }}%</span>
<span *ngIf="showElapsed" class="task-progress-bar__elapsed">
{{ elapsedText }}
</span>
</div>
<!-- Angular Material determinate progress bar -->
<mat-progress-bar
class="task-progress-bar__bar"
mode="determinate"
[value]="clampedProgress"
aria-label="Task progress"
></mat-progress-bar>
</div>

View File

@@ -1,77 +0,0 @@
// ============================================================================
// 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;
}

View File

@@ -1,109 +0,0 @@
// ============================================================================
// 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
* <app-task-progress-bar [progress]="65" />
* <app-task-progress-bar [progress]="42" [showElapsed]="true" />
* ```
*/
@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 (0100). 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<typeof setInterval> | 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 0100 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`;
}
}
}

View File

@@ -1,11 +0,0 @@
// ============================================================================
// OpenClaw Control Center — Design System Barrel Export
// ============================================================================
// Import everything from '@app/design' for convenient access.
//
// Usage:
// import { CcTokens, CcThemeService, CcCssProps } from '@app/design';
// ============================================================================
export * from './tokens';
export * from './theme.service';

View File

@@ -1,151 +0,0 @@
// ============================================================================
// OpenClaw Control Center — Theme Service
// ============================================================================
// Angular service providing programmatic access to design tokens, theme
// mode switching (dark/light), and runtime CSS custom property manipulation.
//
// Usage:
// constructor(private theme: CcThemeService) {}
//
// // Read a token
// const primary = this.theme.getToken('--cc-color-primary');
//
// // Set a token at runtime
// this.theme.setToken('--cc-color-primary', '#00ff00');
//
// // Toggle theme
// this.theme.setMode('light');
// ============================================================================
import { Injectable, signal, computed, effect } from '@angular/core';
import { CcCssProps, getStatusColor, setCssToken, getCssToken } from './tokens';
// ---------------------------------------------------------------------------
// Theme Mode Types
// ---------------------------------------------------------------------------
export type ThemeMode = 'dark' | 'light';
// ---------------------------------------------------------------------------
// Light theme overrides (future use)
// ---------------------------------------------------------------------------
const LIGHT_THEME_OVERRIDES: Record<string, string> = {
// Surface tokens
'--cc-surface-darkest': '#F8FAFC',
'--cc-surface-dark': '#FFFFFF',
'--cc-surface-medium': '#F1F5F9',
'--cc-surface-light': '#E2E8F0',
'--cc-surface-lighter': '#CBD5E1',
// On-surface tokens
'--cc-on-surface': '#0F172A',
'--cc-on-surface-variant': '#475569',
'--cc-on-surface-muted': '#94A3B8',
// Border
'--cc-surface-lighter-alt': '#E2E8F0',
// M3 system overrides for light
'--mat-sys-surface': '#FFFFFF',
'--mat-sys-surface-container': '#F1F5F9',
'--mat-sys-surface-container-high': '#E2E8F0',
'--mat-sys-on-surface': '#0F172A',
'--mat-sys-on-surface-variant': '#475569',
'--mat-sys-outline': '#CBD5E1',
'--mat-sys-background': '#F8FAFC',
};
// ---------------------------------------------------------------------------
// Dark theme (matches the SCSS defaults)
// ---------------------------------------------------------------------------
const DARK_THEME_OVERRIDES: Record<string, string> = {
'--cc-surface-darkest': '#0D0F12',
'--cc-surface-dark': '#13161A',
'--cc-surface-medium': '#1C2027',
'--cc-surface-light': '#252B33',
'--cc-surface-lighter': '#2D3748',
'--cc-on-surface': '#E2E8F0',
'--cc-on-surface-variant': '#8A9BB0',
'--cc-on-surface-muted': '#64748B',
'--mat-sys-surface': '#13161A',
'--mat-sys-surface-container': '#1C2027',
'--mat-sys-surface-container-high': '#252B33',
'--mat-sys-on-surface': '#E2E8F0',
'--mat-sys-on-surface-variant': '#8A9BB0',
'--mat-sys-outline': '#2D3748',
'--mat-sys-background': '#0D0F12',
};
@Injectable({ providedIn: 'root' })
export class CcThemeService {
// ---------------------------------------------------------------------------
// Signals for reactive theme state
// ---------------------------------------------------------------------------
private readonly _mode = signal<ThemeMode>(
(localStorage.getItem('cc-theme') as ThemeMode) ?? 'dark'
);
/** Current theme mode */
readonly mode = this._mode.asReadonly();
/** Computed: is the current mode dark? */
readonly isDark = computed(() => this._mode() === 'dark');
/** Computed: is the current mode light? */
readonly isLight = computed(() => this._mode() === 'light');
constructor() {
// Apply theme on init and whenever mode changes
effect(() => {
this.applyTheme(this._mode());
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Set the theme mode and persist to localStorage */
setMode(mode: ThemeMode): void {
this._mode.set(mode);
localStorage.setItem('cc-theme', mode);
}
/** Toggle between dark and light mode */
toggle(): void {
this.setMode(this._mode() === 'dark' ? 'light' : 'dark');
}
/** Read a CSS custom property from the document root */
getToken(property: string): string {
return getCssToken(property);
}
/** Set a CSS custom property on the document root */
setToken(property: string, value: string): void {
setCssToken(property, value);
}
/** Get status color set by agent status */
getStatusColors(status: string): { fg: string; bg: string; border: string } {
return getStatusColor(status);
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
/** Apply a theme mode by setting all CSS custom properties */
private applyTheme(mode: ThemeMode): void {
const overrides = mode === 'dark' ? DARK_THEME_OVERRIDES : LIGHT_THEME_OVERRIDES;
// Set color-scheme for native form controls
document.documentElement.style.setProperty('color-scheme', mode);
// Apply all overrides
for (const [prop, value] of Object.entries(overrides)) {
document.documentElement.style.setProperty(prop, value);
}
}
}

View File

@@ -1,379 +0,0 @@
// ============================================================================
// OpenClaw Control Center — Design Tokens (TypeScript)
// ============================================================================
// Typed representation of the design system tokens for programmatic access.
// These mirror the SCSS tokens in styles/_tokens.scss and the CSS custom
// properties emitted by styles/_css-properties.scss.
//
// Usage:
// import { CcTokens } from '@app/design/tokens';
// const primary = CcTokens.color.primary;
// const surface = CcTokens.surface.dark;
// ============================================================================
// ---------------------------------------------------------------------------
// Color Palette
// ---------------------------------------------------------------------------
export const CcColors = {
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#38bdf8',
600: '#0ea5e9',
700: '#0284c7',
800: '#0369a1',
900: '#075985',
},
secondary: {
50: '#f0fdfa',
100: '#ccfbf1',
200: '#99f6e4',
300: '#5eead4',
400: '#2dd4bf',
500: '#14b8a6',
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
},
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
} as const;
// ---------------------------------------------------------------------------
// Semantic Colors (Tactical Dark)
// ---------------------------------------------------------------------------
export const CcSemanticColors = {
surface: {
darkest: '#0D0F12',
dark: '#13161A',
medium: '#1C2027',
light: '#252B33',
lighter: '#2D3748',
},
onSurface: {
primary: '#E2E8F0',
variant: '#8A9BB0',
muted: '#64748B',
},
} as const;
// ---------------------------------------------------------------------------
// Status Colors
// ---------------------------------------------------------------------------
export const CcStatusColors = {
active: { fg: '#38bdf8', bg: 'rgba(56, 189, 248, 0.12)', border: 'rgba(56, 189, 248, 0.40)' },
idle: { fg: '#2dd4bf', bg: 'rgba(45, 212, 191, 0.12)', border: 'rgba(45, 212, 191, 0.40)' },
thinking: { fg: '#a78bfa', bg: 'rgba(167, 139, 250, 0.12)', border: 'rgba(167, 139, 250, 0.40)' },
error: { fg: '#f87171', bg: 'rgba(248, 113, 113, 0.12)', border: 'rgba(248, 113, 113, 0.40)' },
offline: { fg: '#64748b', bg: 'rgba(100, 116, 139, 0.12)', border: 'rgba(100, 116, 139, 0.40)' },
} as const;
// ---------------------------------------------------------------------------
// Typography
// ---------------------------------------------------------------------------
export const CcTypography = {
fontFamily: {
brand: "'Inter, Roboto, sans-serif'",
body: "'Inter, Roboto, sans-serif'",
mono: "'Roboto Mono, Cascadia Code, Fira Code, monospace'",
},
size: {
displayLarge: '57px',
displayMedium: '45px',
displaySmall: '36px',
headlineLarge: '32px',
headlineMedium: '28px',
headlineSmall: '24px',
titleLarge: '22px',
titleMedium: '16px',
titleSmall: '14px',
bodyLarge: '16px',
bodyMedium: '14px',
bodySmall: '12px',
labelLarge: '14px',
labelMedium: '12px',
labelSmall: '11px',
},
weight: {
regular: 400,
medium: 500,
bold: 600,
heavy: 700,
},
lineHeight: {
tight: '1.2',
normal: '1.5',
relaxed: '1.6',
},
letterSpacing: {
tight: '-0.01em',
normal: '0em',
wide: '0.02em',
mono: '0.05em',
},
} as const;
// ---------------------------------------------------------------------------
// Spacing (4px grid)
// ---------------------------------------------------------------------------
export const CcSpacing = {
0: '0px',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
5: '20px',
6: '24px',
7: '28px',
8: '32px',
9: '36px',
10: '40px',
12: '48px',
14: '56px',
16: '64px',
20: '80px',
} as const;
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
export const CcLayout = {
navRailCollapsedWidth: '72px',
navRailExpandedWidth: '256px',
headerHeight: '64px',
bottomNavHeight: '80px',
cardBorderRadius: '16px',
cardMinWidth: '320px',
badgeHeight: '24px',
badgeBorderRadius: '12px',
statusDotSize: '10px',
} as const;
// ---------------------------------------------------------------------------
// Breakpoints (M3 canonical)
// ---------------------------------------------------------------------------
export const CcBreakpoints = {
compact: 599,
medium: 767,
expanded: 1023,
large: 1439,
} as const;
// ---------------------------------------------------------------------------
// Border Radius
// ---------------------------------------------------------------------------
export const CcRadius = {
none: '0px',
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '24px',
full: '9999px',
} as const;
// ---------------------------------------------------------------------------
// Shadows (M3 elevation)
// ---------------------------------------------------------------------------
export const CcShadows = {
level0: 'none',
level1: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3)',
level2: '0 2px 6px 0 rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
level3: '0 4px 12px 0 rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.3)',
level4: '0 8px 24px 0 rgba(0, 0, 0, 0.3), 0 8px 16px -8px rgba(0, 0, 0, 0.3)',
} as const;
// ---------------------------------------------------------------------------
// Motion
// ---------------------------------------------------------------------------
export const CcMotion = {
duration: {
instant: 0,
fast: 100,
short: 150,
medium: 200,
standard: 300,
long: 500,
},
easing: {
standard: 'cubic-bezier(0.4, 0, 0.2, 1)',
decelerate: 'cubic-bezier(0, 0, 0.2, 1)',
accelerate: 'cubic-bezier(0.4, 0, 1, 1)',
sharp: 'cubic-bezier(0.4, 0, 0.6, 1)',
},
} as const;
// ---------------------------------------------------------------------------
// Accessibility
// ---------------------------------------------------------------------------
export const CcA11y = {
focusRing: {
width: '2px',
offset: '2px',
color: '#38bdf8',
style: 'solid',
},
minTouchTarget: 48,
minBodyFont: 16,
} as const;
// ---------------------------------------------------------------------------
// Aggregate token object for convenient access
// ---------------------------------------------------------------------------
export const CcTokens = {
color: CcColors,
semantic: CcSemanticColors,
status: CcStatusColors,
typography: CcTypography,
spacing: CcSpacing,
layout: CcLayout,
breakpoints: CcBreakpoints,
radius: CcRadius,
shadows: CcShadows,
motion: CcMotion,
a11y: CcA11y,
} as const;
// ---------------------------------------------------------------------------
// CSS Custom Property Names
// ---------------------------------------------------------------------------
// Use these constants when setting styles programmatically via Renderer2
// or ElementRef.style, e.g.: el.style.setProperty(CcCssProps.colorPrimary, '#fff')
// ---------------------------------------------------------------------------
export const CcCssProps = {
// Color
colorPrimary: '--cc-color-primary',
colorSecondary: '--cc-color-secondary',
colorAccent: '--cc-color-accent',
colorDanger: '--cc-color-danger',
// Surface
surfaceDarkest: '--cc-surface-darkest',
surfaceDark: '--cc-surface-dark',
surfaceMedium: '--cc-surface-medium',
surfaceLight: '--cc-surface-light',
surfaceLighter: '--cc-surface-lighter',
// On-surface
onSurface: '--cc-on-surface',
onSurfaceVariant: '--cc-on-surface-variant',
onSurfaceMuted: '--cc-on-surface-muted',
// Status
statusActive: '--cc-status-active',
statusIdle: '--cc-status-idle',
statusThinking: '--cc-status-thinking',
statusError: '--cc-status-error',
statusOffline: '--cc-status-offline',
statusActiveBg: '--cc-status-active-bg',
statusIdleBg: '--cc-status-idle-bg',
statusThinkingBg: '--cc-status-thinking-bg',
statusErrorBg: '--cc-status-error-bg',
statusOfflineBg: '--cc-status-offline-bg',
statusActiveBorder: '--cc-status-active-border',
statusIdleBorder: '--cc-status-idle-border',
statusThinkingBorder: '--cc-status-thinking-border',
statusErrorBorder: '--cc-status-error-border',
statusOfflineBorder: '--cc-status-offline-border',
// Typography
fontBrand: '--cc-font-brand',
fontBody: '--cc-font-body',
fontMono: '--cc-font-mono',
// Spacing
spacing2: '--cc-spacing-2',
spacing4: '--cc-spacing-4',
spacing6: '--cc-spacing-6',
spacing8: '--cc-spacing-8',
spacing12: '--cc-spacing-12',
spacing16: '--cc-spacing-16',
// Layout
navRailCollapsed: '--cc-nav-rail-collapsed',
navRailExpanded: '--cc-nav-rail-expanded',
headerHeight: '--cc-header-height',
bottomNavHeight: '--cc-bottom-nav-height',
cardRadius: '--cc-card-radius',
cardMinWidth: '--cc-card-min-width',
// Radius
radiusNone: '--cc-radius-none',
radiusXs: '--cc-radius-xs',
radiusSm: '--cc-radius-sm',
radiusMd: '--cc-radius-md',
radiusLg: '--cc-radius-lg',
radiusXl: '--cc-radius-xl',
radiusFull: '--cc-radius-full',
// Shadows
shadow0: '--cc-shadow-0',
shadow1: '--cc-shadow-1',
shadow2: '--cc-shadow-2',
shadow3: '--cc-shadow-3',
shadow4: '--cc-shadow-4',
// Motion
durationFast: '--cc-duration-fast',
durationShort: '--cc-duration-short',
durationMedium: '--cc-duration-medium',
durationStandard: '--cc-duration-standard',
durationLong: '--cc-duration-long',
easingStandard: '--cc-easing-standard',
easingDecelerate: '--cc-easing-decelerate',
easingAccelerate: '--cc-easing-accelerate',
// Accessibility
focusWidth: '--cc-focus-width',
focusOffset: '--cc-focus-offset',
focusColor: '--cc-focus-color',
touchMin: '--cc-touch-min',
} as const;
// ---------------------------------------------------------------------------
// Utility: Read a CSS custom property from the document
// ---------------------------------------------------------------------------
export function getCssToken(propertyName: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(propertyName).trim();
}
// ---------------------------------------------------------------------------
// Utility: Set a CSS custom property on the document root
// ---------------------------------------------------------------------------
export function setCssToken(propertyName: string, value: string): void {
document.documentElement.style.setProperty(propertyName, value);
}
// ---------------------------------------------------------------------------
// Utility: Get status color by agent status type
// ---------------------------------------------------------------------------
export function getStatusColor(status: string): { fg: string; bg: string; border: string } {
const statusMap: Record<string, { fg: string; bg: string; border: string }> = CcStatusColors;
return statusMap[status] ?? CcStatusColors.offline;
}

View File

@@ -1,24 +0,0 @@
<nav class="bottom-nav" aria-label="Bottom navigation">
@for (dest of destinations; track dest.route) {
<a
class="bottom-nav__item"
[routerLink]="dest.route"
routerLinkActive="bottom-nav__item--active"
#rla="routerLinkActive"
[attr.aria-label]="dest.label"
[attr.aria-current]="rla.isActive ? 'page' : null"
>
<span class="bottom-nav__icon-wrapper">
<mat-icon
[matBadge]="dest.badge ?? 0"
[matBadgeHidden]="!dest.badge"
matBadgePosition="above after"
matBadgeSize="small"
>
{{ dest.icon }}
</mat-icon>
</span>
<span class="bottom-nav__label">{{ dest.label }}</span>
</a>
}
</nav>

View File

@@ -1,76 +0,0 @@
// ============================================================================
// Bottom Navigation Bar — Mobile Navigation
// Per spec Section 3.2: M3 NavigationBar pattern
// Visible only on compact breakpoint (< 600px)
// ============================================================================
.bottom-nav {
display: none; // Hidden on desktop, shown on mobile via media query
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--cc-bottom-nav-height);
background-color: var(--cc-surface-container-high);
border-top: 1px solid var(--cc-outline);
z-index: 50;
align-items: center;
justify-content: space-around;
padding: 0 8px;
}
.bottom-nav__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 48px;
min-height: 48px;
padding: 8px 0;
text-decoration: none;
color: var(--cc-on-surface-variant);
border-radius: 16px;
transition: color 150ms ease, background-color 150ms ease;
&:hover {
color: var(--cc-on-surface);
background-color: rgba(255, 255, 255, 0.06);
}
&--active {
color: var(--status-active);
background-color: var(--status-active-bg);
.bottom-nav__label {
font-weight: 500;
}
}
}
.bottom-nav__icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 16px;
.bottom-nav__item--active & {
background-color: var(--status-active-bg);
}
}
.bottom-nav__label {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
white-space: nowrap;
}
// Show bottom nav only on compact breakpoint
@media (max-width: 599px) {
.bottom-nav {
display: flex;
}
}

View File

@@ -1,24 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatBadgeModule } from '@angular/material/badge';
import { NAV_DESTINATIONS } from '../../models/nav.model';
/**
* Bottom Navigation Bar for mobile (compact breakpoint).
* Per spec Section 3.2: M3 NavigationBar, 35 destinations,
* active destination uses M3 indicator pill.
* Visible only on compact (< 600px) breakpoint.
*/
@Component({
selector: 'app-bottom-nav',
standalone: true,
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
templateUrl: './bottom-nav.component.html',
styleUrl: './bottom-nav.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BottomNavComponent {
/** Show only first 5 destinations on bottom nav */
protected readonly destinations = NAV_DESTINATIONS.slice(0, 5);
}

View File

@@ -1,55 +0,0 @@
<header class="header-bar" role="banner">
<h1 class="header-bar__title">Command Hub</h1>
<div class="header-bar__actions">
<!-- Quick-Jump trigger -->
<button
class="header-bar__action-btn"
mat-icon-button
aria-label="Jump to agent"
(click)="openQuickJump.emit()"
>
<mat-icon>keyboard_command_key</mat-icon>
</button>
<!-- Live indicator -->
<button
class="header-bar__action-btn header-bar__live-btn"
mat-icon-button
[attr.aria-label]="isConnected() ? 'Connected live' : 'Disconnected'"
>
<span
class="header-bar__live-dot"
[class.header-bar__live-dot--connected]="isConnected()"
></span>
<span class="header-bar__live-label">
{{ isConnected() ? 'Live' : 'Offline' }}
</span>
</button>
<!-- Notification bell -->
<button
class="header-bar__action-btn"
mat-icon-button
aria-label="Notifications"
>
<mat-icon
[matBadge]="notificationCount()"
[matBadgeHidden]="notificationCount() === 0"
matBadgePosition="above after"
matBadgeSize="small"
>
notifications
</mat-icon>
</button>
<!-- Settings -->
<button
class="header-bar__action-btn"
mat-icon-button
aria-label="Settings"
>
<mat-icon>settings</mat-icon>
</button>
</div>
</header>

View File

@@ -1,76 +0,0 @@
// ============================================================================
// Header Bar — Top App Bar
// Per spec Section 3.1: 64px tall, M3 MediumTopAppBar on expanded
// Section 3.2: SmallTopAppBar on mobile
// ============================================================================
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--cc-header-height);
padding: 0 var(--cc-section-padding);
background-color: var(--cc-surface-container-high);
border-bottom: 1px solid var(--cc-outline);
z-index: 20;
}
.header-bar__title {
font-size: 28px;
font-weight: 400;
color: var(--cc-on-surface);
margin: 0;
letter-spacing: -0.01em;
}
.header-bar__actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-bar__action-btn {
color: var(--cc-on-surface-variant) !important;
&:hover {
color: var(--cc-on-surface) !important;
}
}
.header-bar__live-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
background-color: var(--status-error);
vertical-align: middle;
&--connected {
background-color: var(--status-active);
animation: pulse-active 2s ease-in-out infinite;
}
}
.header-bar__live-label {
font-size: 13px;
font-weight: 500;
color: var(--cc-on-surface-variant);
vertical-align: middle;
}
// Mobile: smaller title
@media (max-width: 599px) {
.header-bar {
padding: 0 16px;
}
.header-bar__title {
font-size: 22px;
font-weight: 500;
}
.header-bar__live-label {
display: none; // Space saving on mobile — dot alone is enough
}
}

View File

@@ -1,28 +0,0 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Output, signal } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatBadgeModule } from '@angular/material/badge';
/**
* Header Bar component for the Command Hub.
* Per spec Section 3.1: 64px tall, app title + live indicator + notification bell + settings.
* Uses M3 top app bar pattern.
*/
@Component({
selector: 'app-header-bar',
standalone: true,
imports: [MatIconModule, MatButtonModule, MatBadgeModule],
templateUrl: './header-bar.component.html',
styleUrl: './header-bar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderBarComponent {
/** Emits when the user requests the Quick-Jump drawer. */
@Output() readonly openQuickJump = new EventEmitter<void>();
protected readonly notificationCount = signal(3);
protected readonly isConnected = signal(true);
// TODO: Wire up notification panel (spec Section 2: Notifications Panel)
// TODO: Wire up settings navigation
}

View File

@@ -1,4 +0,0 @@
export * from './nav-rail/nav-rail.component';
export * from './bottom-nav/bottom-nav.component';
export * from './header-bar/header-bar.component';
export * from './layout-shell/layout-shell.component';

View File

@@ -1,20 +0,0 @@
<div class="layout-shell">
<!-- Desktop/Kiosk: Nav Rail on the left -->
<app-nav-rail class="layout-shell__nav-rail" />
<div class="layout-shell__main">
<!-- Header bar at top of content area -->
<app-header-bar class="layout-shell__header" (openQuickJump)="openQuickJump()" />
<!-- Scrollable content area -->
<main class="layout-shell__content">
<router-outlet />
</main>
</div>
<!-- Mobile: Bottom Navigation Bar -->
<app-bottom-nav class="layout-shell__bottom-nav" />
</div>
<!-- Quick-Jump Drawer (global overlay) -->
<app-quick-jump-drawer />

View File

@@ -1,57 +0,0 @@
// ============================================================================
// Layout Shell — Adaptive layout container
// Desktop: Nav Rail (left) + Main Content (right)
// Mobile: Header + Content + Bottom Nav (stacked)
// ============================================================================
.layout-shell {
display: flex;
min-height: 100vh;
background-color: var(--cc-background);
}
.layout-shell__nav-rail {
flex-shrink: 0;
}
.layout-shell__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; // Prevent flex overflow
overflow: hidden;
}
.layout-shell__header {
flex-shrink: 0;
}
.layout-shell__content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--cc-section-padding);
}
.layout-shell__bottom-nav {
flex-shrink: 0;
}
// Mobile: Stack layout vertically, add bottom padding for bottom nav
@media (max-width: 599px) {
.layout-shell {
flex-direction: column;
}
.layout-shell__content {
// Account for bottom nav bar height
padding-bottom: calc(var(--cc-bottom-nav-height) + 16px);
}
}
// Tablet: Ensure content padding accommodates collapsed nav rail
@media (min-width: 600px) and (max-width: 1023px) {
.layout-shell__content {
padding: 20px;
}
}

Some files were not shown because too many files have changed in this diff Show More