Compare commits

..

41 Commits

Author SHA1 Message Date
ff9f6fec83 CUB-27: Responsive layout and adaptive navigation
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m7s
2026-04-28 08:43:57 -04:00
8331468b44 ci: simplify dev pipeline to build-test only (api-client has TS errors, remove for now)
All checks were successful
Dev Build / build-test (push) Successful in 3m46s
2026-04-27 21:00:07 -04:00
e5d9b7ea07 fix: use npm install instead of npm ci for api-client (no package-lock.json)
Some checks failed
Dev Build / notify-failure (push) Successful in 37s
Dev Build / build-test (push) Failing after 3m26s
Dev Build / notify-success (push) Has been skipped
2026-04-27 20:48:55 -04:00
0108d8aca0 ci: add dev build pipeline with backend (.NET) and frontend (Angular) jobs
Some checks failed
Dev Build / build-test (push) Failing after 5m9s
Dev Build / notify-success (push) Has been skipped
Dev Build / notify-failure (push) Successful in 46s
2026-04-27 20:31:22 -04:00
27d877db6c docs: add comprehensive README 2026-04-27 18:49:32 -04:00
29dd9321f8 Merge pull request 'CUB-51: Quick-Jump Drawer Component (Slide-Out)' (#14) from agent/rex/CUB-51-quick-jump-drawer into dev
Reviewed-on: #14
Reviewed-by: Otto the Minion <otto@code.cubecraftcreations.com>
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-04-27 17:55:21 -04:00
Joshua King
64adffa0b4 Merge branch 'dev' into agent/rex/CUB-51-quick-jump-drawer 2026-04-27 17:54:48 -04:00
Joshua King
3a0efaa5a4 Merge branch 'agent/rex/CUB-46-quick-jump-button' into dev 2026-04-27 17:51:27 -04:00
2a99ace9f8 Merge pull request 'CUB-49: Global Action Modal Component' (#18) from agent/rex/CUB-49-global-action-modal into dev
Reviewed-on: #18
Reviewed-by: Otto the Minion <otto@code.cubecraftcreations.com>
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-04-27 17:45:55 -04:00
0ddffaf266 Merge branch 'dev' into agent/rex/CUB-49-global-action-modal 2026-04-27 17:45:24 -04:00
55fd2cd0d2 Merge pull request 'CUB-50: Implement Adaptive Navigation Component' (#17) from agent/rex/CUB-50-adaptive-navigation into dev
Reviewed-on: #17
Reviewed-by: Otto the Minion <otto@code.cubecraftcreations.com>
2026-04-27 17:45:09 -04:00
44b4758747 Merge branch 'dev' into agent/rex/CUB-50-adaptive-navigation 2026-04-27 17:44:59 -04:00
45b9068acc Merge pull request 'CUB-44: Implement Task Progress Bar Component' (#15) from agent/rex/CUB-44-task-progress-bar into dev
Reviewed-on: #15
Reviewed-by: Otto the Minion <otto@code.cubecraftcreations.com>
2026-04-27 17:44:46 -04:00
e39fd45018 Merge branch 'dev' into agent/rex/CUB-44-task-progress-bar 2026-04-27 17:44:35 -04:00
5fd37b556a Merge pull request 'feat(CUB-45): AgentCard Component Final Integration' (#19) from agent/rex/CUB-45-agent-card-final into dev
Reviewed-on: #19
Reviewed-by: Otto the Minion <otto@code.cubecraftcreations.com>
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-04-27 17:44:16 -04:00
a2567dd3aa Merge branch 'dev' into agent/rex/CUB-45-agent-card-final 2026-04-27 17:43:54 -04:00
512a3364cf Merge pull request 'CUB-54: Implement Agent State Repository (EF Core)' (#23) from agent/dex/CUB-54-agent-state-repository into dev
Reviewed-on: #23
2026-04-27 17:42:57 -04:00
bca3bf7677 Merge branch 'dev' into agent/dex/CUB-54-agent-state-repository 2026-04-27 17:42:41 -04:00
1c012de47b Merge pull request 'feat(CUB-62): [Control Center] Agent-to-Minion Mapping Service' (#24) from agent/dex/CUB-62-agent-minion-mapper into dev
Reviewed-on: #24
2026-04-27 17:42:30 -04:00
ea603c3552 Merge branch 'dev' into agent/dex/CUB-62-agent-minion-mapper 2026-04-27 17:42:16 -04:00
bcaf85c369 Merge pull request 'CUB-55: Add SendStatusUpdate method to AgentStatusHub' (#25) from agent/dex/CUB-55-signalr-broadcast into dev
Reviewed-on: #25
2026-04-27 17:42:10 -04:00
84e3d5420e Merge branch 'dev' into agent/dex/CUB-55-signalr-broadcast 2026-04-27 17:40:52 -04:00
8bdbcae13a Merge pull request 'CUB-48: Agent Status Badge component with pulse animations' (#26) from agent/rex/CUB-48-agent-status-badge into dev
Reviewed-on: #26
2026-04-27 17:40:37 -04:00
db91c8bde9 Merge branch 'dev' into agent/rex/CUB-48-agent-status-badge 2026-04-27 17:40:21 -04:00
e2582569b0 Merge branch 'dev' into agent/rex/CUB-51-quick-jump-drawer 2026-04-27 16:56:49 -04:00
53454e0635 Merge pull request 'CUB-47: Implement Tactical Dark Mode CSS Variables' (#13) from agent/rex/CUB-47-dark-mode-variables into dev
Reviewed-on: #13
Reviewed-by: Otto the Minion <otto@code.cubecraftcreations.com>
2026-04-27 16:07:10 -04:00
d06caeab8e Merge branch 'dev' into agent/rex/CUB-47-dark-mode-variables 2026-04-27 16:06:56 -04:00
49a9a95086 Merge pull request 'feat(CUB-52): Responsive Hub Grid CSS' (#12) from agent/rex/CUB-52-responsive-hub-grid into dev
Reviewed-on: #12
Reviewed-by: Otto the Minion <otto@code.cubecraftcreations.com>
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-04-27 16:06:34 -04:00
2a21cad431 feat(CUB-45): [Control Center] AgentCard Component Final Integration 2026-04-27 14:36:14 +00:00
5375d11792 CUB-48: Agent Status Badge component with pulse animations 2026-04-27 14:17:04 +00:00
ed1ee886db CUB-49: [Control Center] Global Action Modal Component 2026-04-27 14:08:57 +00:00
cubecraft-agents[bot]
c8ca182af0 CUB-52: responsive hub grid CSS — extract styles to SCSS, add 2-col desktop / 1-col mobile breakpoint 2026-04-27 12:40:29 +00:00
cubecraft-agents[bot]
fb88eab4d1 feat(CUB-55): add SendStatusUpdate method to AgentStatusHub 2026-04-27 10:07:07 +00:00
cubecraft-agents[bot]
82c12554d0 feat(CUB-62): [Control Center] Agent-to-Minion Mapping Service 2026-04-27 09:11:30 +00:00
cubecraft-agents[bot]
f170def0ea feat(CUB-54): implement Agent State Repository with EF Core
- Add AgentState read model (Models/AgentState.cs)
- Add IAgentStateRepository interface with GetAllAsync,
  GetBySessionKeyAsync, UpdateStatusAsync
- Add AgentStateRepository EF Core implementation
  mapping Agent entity → AgentState model
- Register IAgentStateRepository in DI (Program.cs)
- Exclude ControlCenter sub-project from Api compilation

Build: 0 warnings, 0 errors
2026-04-27 04:10:18 +00:00
cubecraft-agents[bot]
040d4cb54d fix(CUB-56): Restore Program.cs deletion - PR should only add Swashbuckle package 2026-04-27 02:24:51 +00:00
cubecraft-agents[bot]
47cbeed456 feat(CUB-56): [Control Center] Agent State Database Migration 2026-04-26 22:07:05 +00:00
cubecraft-agents[bot]
d2da0c160f CUB-50: Implement Adaptive Navigation Component 2026-04-26 13:32:59 +00:00
cubecraft-agents[bot]
14b3dab88b CUB-44: add task-progress-bar component with determinate mode and elapsed time 2026-04-26 13:09:18 +00:00
cubecraft-agents[bot]
b4e110f4c3 CUB-51: Implement Quick-Jump Drawer Component 2026-04-26 13:06:24 +00:00
cubecraft-agents[bot]
d5a85c4ed0 CUB-47: Implement Tactical Dark Mode CSS Variables 2026-04-26 12:54:25 +00:00
51 changed files with 3713 additions and 406 deletions

44
.gitea/workflows/dev.yml Normal file
View File

@@ -0,0 +1,44 @@
name: Dev Build
on:
pull_request:
branches: [dev]
push:
branches: [dev]
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Restore backend
run: dotnet restore
working-directory: ./backend
- name: Build backend
run: dotnet build --no-restore --configuration Release
working-directory: ./backend
- name: Test backend
run: dotnet test --no-build --configuration Release
working-directory: ./backend
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install frontend deps
run: npm ci
working-directory: ./frontend
- name: Build frontend
run: npm run build
working-directory: ./frontend

223
README.md
View File

@@ -0,0 +1,223 @@
# Control Center
> Real-time agent fleet dashboard for the OpenClaw AI development team.
Control Center monitors and controls the full CubeCraft Creations AI agent fleet — Otto, Rex, Dex, Hex, Pip, Nano, Sketch, Bob, Stuart, Norbert, and Flip. It provides live agent status, task progress, session logs, and a command interface, backed by SignalR for push-based updates directly from the OpenClaw gateway.
---
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | ASP.NET Core Web API (.NET 8) |
| Database | PostgreSQL (snake_case via EF Core) |
| ORM | Entity Framework Core |
| Real-time | SignalR (`AgentStatusHub`) |
| Frontend | Angular (latest), Angular Material, Angular Signals |
| API Client | TypeScript package (`api-client/`) |
| Deployment | Docker |
---
## Project Structure
```
Control-Center/
├── backend/
│ ├── ControlCenter/
│ │ ├── Controllers/ # AgentsController, CommandController, LogsController
│ │ ├── Hubs/ # AgentStatusHub, IAgentStatusClient, AgentStatusModels
│ │ ├── Models/ # AgentMinionMapping
│ │ ├── Services/ # AgentMinionMapperService, GatewayEventBridgeService
│ │ ├── Program.cs
│ │ └── appsettings.json
│ ├── Configurations/ # AgentConfiguration
│ ├── Data/ # AppDbContext, AppDbContextFactory
│ ├── Dtos/ # AgentStatusUpdateDto
│ ├── Entities/ # Agent, AgentStatus
│ ├── Migrations/ # EF migrations
│ ├── Models/ # AgentState
│ └── Repositories/ # AgentStateRepository, IAgentStateRepository
├── frontend/
│ └── src/app/
│ ├── layout/ # LayoutShell, HeaderBar, NavRail, BottomNav
│ ├── pages/ # hub, logs, projects, sessions, settings
│ ├── components/ # AgentCard, AgentStatusBadge, TaskProgressBar,
│ │ # QuickJumpButton, QuickJumpDrawer,
│ │ # GlobalActionModal, AdaptiveNavigation
│ ├── command-hub/ # AgentCard command hub view
│ ├── models/ # Agent model, AgentStatus, AgentCardData, Nav
│ └── services/ # AgentStatusService (Angular Signals)
├── api-client/ # Shared TS client (models, SignalR WS, HTTP utils)
├── design/
│ ├── command-hub-spec.md
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
└── README.md
```
---
## Domain Model
### Agent
| Field | Type | Description |
|---|---|---|
| `Id` | `Guid` | Primary key |
| `Status` | `AgentStatus` | Current agent state |
| `Task` | `string?` | Active task description |
| `Progress` | `int?` | Task completion % (0100) |
| `SessionKey` | `string` | OpenClaw session identifier |
| `Channel` | `string` | Source channel (telegram, slack, etc.) |
| `LastActivity` | `DateTime` | Last event timestamp |
| `CreatedAt` / `UpdatedAt` | `DateTime` | Audit fields |
### AgentStatus enum
| Value | Integer | Meaning |
|---|---|---|
| `Active` | 0 | Agent is executing a task |
| `Idle` | 1 | Agent is waiting for work |
| `Thinking` | 2 | Agent is reasoning/planning |
| `Error` | 3 | Agent hit an unrecoverable state |
---
## Key Design Decisions
1. **Angular Signals** — Reactive state management. No NgRx — kept intentionally simple.
2. **Adaptive layout** — Sidebar nav (NavRail) on desktop/kiosk (≥768px); bottom nav on mobile.
3. **Tactical Dark Mode** — Theme via CSS custom properties in `styles.scss`.
4. **SignalR fleet group** — Clients call `JoinFleet()` to subscribe to all agent updates broadcast by the hub.
5. **Backend push model** — Hub uses `IHubContext<AgentStatusHub, IAgentStatusClient>` extension methods to push to clients; no polling.
6. **Gateway bridge**`GatewayEventBridgeService` connects the OpenClaw gateway WebSocket to the SignalR hub.
---
## Getting Started
### Prerequisites
- .NET 8 SDK
- Node.js 20+
- Docker
- PostgreSQL
- Running OpenClaw gateway (for live agent events)
### Backend
```bash
cd backend
# Restore and build
dotnet restore
dotnet build
# Apply migrations
dotnet ef database update --project ControlCenter
# Run API
dotnet run --project ControlCenter
```
API runs at `http://localhost:5000` · Swagger at `http://localhost:5000/swagger`
### Frontend
```bash
cd frontend
npm install
ng serve
```
Frontend runs at `http://localhost:4200`
---
## Configuration
`backend/ControlCenter/appsettings.json` — override in `appsettings.Development.json` or environment variables:
| Key | Default | Description |
|---|---|---|
| `ConnectionStrings:DefaultConnection` | *(set in dev config)* | PostgreSQL connection string |
| `Gateway:WebSocketUrl` | `ws://localhost:3271/ws` | OpenClaw gateway WebSocket URL |
| `Gateway:AuthToken` | `""` | Gateway auth token |
| `Cors:AllowedOrigins` | `localhost:4200`, `localhost:5000` | Frontend origins for CORS |
---
## Real-Time Events
SignalR hub endpoint: `/hubs/agent-status`
### Hub Methods (server → client)
| Method | Payload | Description |
|---|---|---|
| `ReceiveStatusUpdate` | `AgentStatusUpdateDto` | Agent status changed |
| `ReceiveAgentList` | `Agent[]` | Full fleet snapshot on join |
### Client → Server
| Method | Description |
|---|---|
| `JoinFleet()` | Subscribe to all agent updates |
| `LeaveFleet()` | Unsubscribe |
| `SendStatusUpdate(dto)` | Push a status update (internal use) |
---
## Pages
| Route | Page | Description |
|---|---|---|
| `/hub` | Command Hub | Live agent grid — status, task, progress |
| `/logs` | Logs | Agent session log viewer |
| `/projects` | Projects | Linear project tracking view |
| `/sessions` | Sessions | OpenClaw session browser |
| `/settings` | Settings | App configuration |
---
## Agent Fleet
| Agent | Role |
|---|---|
| Otto | Orchestrator — owns the dev lifecycle |
| Rex | Frontend (Angular) |
| Dex | Backend (ASP.NET Core) |
| Hex | Database (schema, migrations) |
| Pip | Raspberry Pi / Python |
| Nano | Arduino / ESP32 / ESPHome |
| Sketch | UX/UI design |
| Flip | Mobile development |
| Bob | Content & marketing copy |
| Stuart | Concept visualization (images) |
| Norbert | 3D design (OpenSCAD) |
---
## Branch & PR Rules
- All feature branches target `dev`**never `main`**
- Branch naming: `agent/<agent>/CUB-N-short-description`
- PR titles: `CUB-N: short description`
- PRs require Otto review before Joshua merges
---
## API Overview
| Route prefix | Resource |
|---|---|
| `/api/agents` | Agent registry and status |
| `/api/command` | Issue commands to agents |
| `/api/logs` | Session log retrieval |
Full schema at `/swagger` when running in dev.
---
*Built by CubeCraft Creations · Orchestrated by Otto*

View File

@@ -6,6 +6,11 @@
<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">
@@ -13,6 +18,7 @@
<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

@@ -37,6 +37,33 @@ public class AgentStatusHub : Hub<IAgentStatusClient>
_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

View File

@@ -72,6 +72,80 @@ public record TaskProgressUpdate(
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.

View File

@@ -0,0 +1,72 @@
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

@@ -52,6 +52,11 @@ builder.Services.AddSignalR();
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 ──────────────────────────────────────────────

View File

@@ -0,0 +1,193 @@
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

@@ -0,0 +1,19 @@
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,5 +1,6 @@
using ControlCenter.Api.Data;
using ControlCenter.Api.Hubs;
using ControlCenter.Api.Repositories;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -10,6 +11,9 @@ builder.Services.AddOpenApi();
// Register SignalR for real-time agent status updates
builder.Services.AddSignalR();
// Register Agent State Repository
builder.Services.AddScoped<IAgentStateRepository, AgentStateRepository>();
// Register DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
{

View File

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,27 @@
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,344 +1 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * 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

@@ -5,7 +5,7 @@ import { RouterOutlet } from '@angular/router';
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
template: '<router-outlet />',
styles: [`
:host {
display: block;

View File

@@ -0,0 +1,82 @@
<!-- ========================================================================== -->
<!-- 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

@@ -0,0 +1,234 @@
// ============================================================================
// 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

@@ -0,0 +1,127 @@
import {
ChangeDetectionStrategy,
Component,
Input,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AgentStatus } from '../../../models/agent.model';
// ============================================================================
// AgentCard Component
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
// and 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

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

View File

@@ -0,0 +1,112 @@
<!-- ======================================================================== -->
<!-- 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

@@ -0,0 +1,365 @@
// ============================================================================
// Adaptive Navigation — Desktop sidebar / Mobile header
// Per CUB-27 spec breakpoints:
// Compact (0599px): Mobile header + hamburger + drawer
// Medium (6001023px): Collapsed sidebar (icon-only, 72px)
// Expanded (≥1024px): Full sidebar with labels (72px collapsed, 256px expanded)
// ============================================================================
// ---------------------------------------------------------------------------
// Desktop Sidebar (visible ≥600px)
// ---------------------------------------------------------------------------
.adaptive-nav__sidebar {
display: none; // Hidden by default (mobile-first)
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 <600px only)
// ---------------------------------------------------------------------------
.adaptive-nav__mobile-header {
display: none; // Hidden on desktop, shown on mobile via media query
align-items: center;
height: var(--cc-header-height-compact, 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;
min-width: 48px;
min-height: 48px;
&: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;
min-width: 48px;
min-height: 48px;
&: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: var(--cc-header-height-compact, 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 (CUB-27 breakpoints)
// ---------------------------------------------------------------------------
// Compact (0599px): Show mobile header, hide sidebar
@media (max-width: 599px) {
.adaptive-nav__sidebar {
display: none;
}
.adaptive-nav__mobile-header {
display: flex;
}
// Hide mobile drawer and overlay on desktop
.adaptive-nav__overlay,
.adaptive-nav__mobile-drawer {
// These are conditional via @if in template, no display:none needed
}
}
// Medium (6001023px): Show collapsed sidebar (icon-only), hide mobile
@media (min-width: 600px) and (max-width: 1023px) {
.adaptive-nav__sidebar {
display: flex;
width: var(--cc-nav-rail-collapsed-width, 72px);
}
// Hide labels on medium (collapsed)
.adaptive-nav__sidebar-label,
.adaptive-nav__brand {
display: none;
}
.adaptive-nav__sidebar-header {
justify-content: center;
}
.adaptive-nav__sidebar-item {
flex-direction: column;
justify-content: center;
}
.adaptive-nav__mobile-header {
display: none;
}
.adaptive-nav__overlay,
.adaptive-nav__mobile-drawer {
display: none;
}
}
// Expanded (≥1024px): Show sidebar with labels
@media (min-width: 1024px) {
.adaptive-nav__sidebar {
display: flex;
width: var(--cc-nav-rail-collapsed-width, 72px);
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.adaptive-nav__mobile-header {
display: none;
}
.adaptive-nav__overlay,
.adaptive-nav__mobile-drawer {
display: none;
}
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.adaptive-nav__live-dot--connected {
animation: none;
}
.adaptive-nav__mobile-drawer {
animation: none;
}
.adaptive-nav__sidebar {
transition: none;
}
}

View File

@@ -0,0 +1,84 @@
import { ChangeDetectionStrategy, Component, signal, HostListener, OnDestroy, OnInit } 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 + JS breakpoint sync.
*
* Per CUB-27 spec breakpoints:
* Compact (0599px): Mobile header + hamburger + bottom nav
* Medium (6001023px): Collapsed sidebar (icon-only)
* Expanded (≥1024px): Expandable sidebar (hover/click)
*
* The LIVE status indicator is visible in all layouts.
*/
@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 implements OnInit, OnDestroy {
/** 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);
/** Responsive breakpoint state */
protected readonly isMedium = signal(false);
protected readonly isExpanded = signal(false);
private readonly COMPACT_MAX = 599;
private readonly MEDIUM_MAX = 1023;
ngOnInit(): void {
this.updateBreakpoint();
}
@HostListener('window:resize')
onResize(): void {
this.updateBreakpoint();
}
/** Toggle mobile menu */
toggleMobileMenu(): void {
this.mobileMenuOpen.update((v) => !v);
}
/** Close mobile menu (e.g. on nav) */
closeMobileMenu(): void {
this.mobileMenuOpen.set(false);
}
private updateBreakpoint(): void {
const w = window.innerWidth;
this.isMedium.set(w >= this.COMPACT_MAX + 1 && w <= this.MEDIUM_MAX);
this.isExpanded.set(w > this.MEDIUM_MAX);
// Close mobile menu when switching to desktop
if (w > this.COMPACT_MAX) {
this.mobileMenuOpen.set(false);
}
}
ngOnDestroy(): void {
// HostListener auto-unsubscribes
}
}

View File

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

View File

@@ -0,0 +1,8 @@
<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

@@ -0,0 +1,146 @@
// ============================================================================
// Agent Status Badge — per spec Section 7.3
// Colored pill with dot indicator and optional pulse animation.
// ============================================================================
$badge-height: 24px;
$dot-size: 8px;
$border-radius: 12px;
$font-size: 12px;
$font-weight: 500;
$padding-x: 8px;
$gap: 6px;
@use 'sass:color';
// Status color palette
$color-active: #22c55e; // green-500
$color-idle: #9ca3af; // gray-400
$color-thinking: #3b82f6; // blue-500
$color-error: #ef4444; // red-500
$color-offline: #9ca3af; // gray-400
// Background tints (12% opacity for soft pill background)
$bg-active: rgba($color-active, 0.12);
$bg-idle: rgba($color-idle, 0.12);
$bg-thinking: rgba($color-thinking, 0.12);
$bg-error: rgba($color-error, 0.12);
$bg-offline: rgba($color-offline, 0.12);
// ---------------------------------------------------------------------------
// Base pill
// ---------------------------------------------------------------------------
.badge {
display: inline-flex;
align-items: center;
height: $badge-height;
padding: 0 $padding-x;
border-radius: $border-radius;
gap: $gap;
font-size: $font-size;
font-weight: $font-weight;
line-height: 1;
white-space: nowrap;
user-select: none;
}
// ---------------------------------------------------------------------------
// Dot indicator
// ---------------------------------------------------------------------------
.badge__dot {
width: $dot-size;
height: $dot-size;
border-radius: 50%;
flex-shrink: 0;
}
// ---------------------------------------------------------------------------
// Label text
// ---------------------------------------------------------------------------
.badge__label {
line-height: 1;
}
// ---------------------------------------------------------------------------
// Status color variants
// ---------------------------------------------------------------------------
.badge--active {
background: $bg-active;
color: color.adjust($color-active, $lightness: -10%);
.badge__dot {
background: $color-active;
}
}
.badge--idle {
background: $bg-idle;
color: color.adjust($color-idle, $lightness: -15%);
.badge__dot {
background: $color-idle;
}
}
.badge--thinking {
background: $bg-thinking;
color: color.adjust($color-thinking, $lightness: -10%);
.badge__dot {
background: $color-thinking;
}
}
.badge--error {
background: $bg-error;
color: color.adjust($color-error, $lightness: -10%);
.badge__dot {
background: $color-error;
}
}
.badge--offline {
background: $bg-offline;
color: color.adjust($color-offline, $lightness: -15%);
.badge__dot {
background: $color-offline;
}
}
// ---------------------------------------------------------------------------
// Pulse animation — applied when status is active, thinking, or error
// ---------------------------------------------------------------------------
.badge--pulse {
.badge__dot {
animation: pulse-dot 2s ease-in-out infinite;
}
}
// Active: 2s pulse
.badge--active.badge--pulse .badge__dot {
animation-duration: 2s;
}
// Thinking: 3s pulse
.badge--thinking.badge--pulse .badge__dot {
animation-duration: 3s;
}
// Error: 0.8s pulse (fast, urgent)
.badge--error.badge--pulse .badge__dot {
animation-duration: 0.8s;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.5);
}
}

View File

@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { AgentStatus } from '../../models/agent.model';
/**
* Agent Status Badge component.
* Displays a colored pill with a pulse animation indicating the agent's current status.
* Per spec Section 7.3: Agent Card Component Interface — status indicator.
*
* Color mapping:
* - Active → green
* - Idle → gray
* - Thinking → blue
* - Error → red
* - Offline → gray (no pulse)
*
* Pulse animations:
* - Active → 2s
* - Error → 0.8s
* - Thinking → 3s
* - Idle / Offline → no pulse
*/
@Component({
selector: 'app-agent-status-badge',
standalone: true,
imports: [],
templateUrl: './agent-status-badge.component.html',
styleUrl: './agent-status-badge.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentStatusBadgeComponent {
/** Current agent status — binds to the AgentStatus type from the model. */
readonly status = input.required<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

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

View File

@@ -0,0 +1,31 @@
<!-- 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

@@ -0,0 +1,198 @@
// ============================================================================
// Global Action Modal — Tactical Dark Mode Styling
// Uses Control Center design tokens from styles.scss
// ============================================================================
// ---------------------------------------------------------------------------
// Backdrop
// ---------------------------------------------------------------------------
:host {
display: block;
position: fixed;
inset: 0;
z-index: 1000;
}
.global-action-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
// ---------------------------------------------------------------------------
// Modal Panel
// ---------------------------------------------------------------------------
.global-action-modal__panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(560px, calc(100vw - 48px));
background: var(--cc-surface-container);
border: 1px solid var(--cc-outline);
border-radius: var(--cc-card-border-radius);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
.global-action-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 12px;
}
.global-action-modal__title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--cc-on-surface);
letter-spacing: 0.01em;
}
.global-action-modal__close {
--mat-icon-button-state-layer-color: transparent;
color: var(--cc-on-surface-variant);
&:hover {
color: var(--cc-on-surface);
}
}
// ---------------------------------------------------------------------------
// Action Grid
// ---------------------------------------------------------------------------
.global-action-modal__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px 24px 24px;
}
// ---------------------------------------------------------------------------
// Action Button
// ---------------------------------------------------------------------------
.global-action-modal__action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 16px;
border: 1px solid var(--cc-outline);
border-radius: 12px;
background: var(--cc-surface);
color: var(--cc-on-surface);
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
font-family: inherit;
text-align: center;
&:hover {
background: var(--cc-surface-container-high);
border-color: var(--cc-on-surface-variant);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary, #38BDF8);
outline-offset: 2px;
}
}
// Action icon wrapper
.global-action-modal__action-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
.mat-icon {
width: 28px;
height: 28px;
font-size: 28px;
}
}
// Action label
.global-action-modal__action-label {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
}
// Action description
.global-action-modal__action-desc {
font-size: 12px;
color: var(--cc-on-surface-variant);
line-height: 1.4;
}
// ---------------------------------------------------------------------------
// Color Variants — per-action accent colors
// ---------------------------------------------------------------------------
.global-action-modal__action-btn--deploy {
.global-action-modal__action-icon {
background: var(--status-active-bg);
color: var(--status-active);
}
&:hover {
border-color: var(--status-active);
}
}
.global-action-modal__action-btn--pause {
.global-action-modal__action-icon {
background: var(--status-idle-bg);
color: var(--status-idle);
}
&:hover {
border-color: var(--status-idle);
}
}
.global-action-modal__action-btn--emergency {
.global-action-modal__action-icon {
background: var(--status-error-bg);
color: var(--status-error);
}
&:hover {
border-color: var(--status-error);
}
.global-action-modal__action-label {
color: var(--status-error);
}
}
.global-action-modal__action-btn--add {
.global-action-modal__action-icon {
background: var(--status-thinking-bg);
color: var(--status-thinking);
}
&:hover {
border-color: var(--status-thinking);
}
}
// ---------------------------------------------------------------------------
// Responsive — stack single column on narrow viewports
// ---------------------------------------------------------------------------
@media (max-width: 400px) {
.global-action-modal__actions {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,87 @@
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
/**
* Global Action Modal — overlay for fleet-wide commands.
*
* Four main actions: Deploy All, Pause All, Emergency Stop, Add Agent.
* Tactical Dark Mode styling using Control Center design tokens.
* Dismisses on backdrop click or close button.
*/
@Component({
selector: 'app-global-action-modal',
standalone: true,
imports: [MatIconModule, MatButtonModule],
templateUrl: './global-action-modal.component.html',
styleUrl: './global-action-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalActionModalComponent {
/** Emitted when any action button is clicked. Payload is the action key. */
@Output() readonly actionSelected = new EventEmitter<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 +1,3 @@
export * from './quick-jump-button/quick-jump-button.component';
export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component';
export { QuickJumpDrawerComponent } from './quick-jump-drawer/index';

View File

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

View File

@@ -0,0 +1,109 @@
<!-- ============================================================================
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

@@ -0,0 +1,333 @@
// ============================================================================
// 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

@@ -0,0 +1,215 @@
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

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

View File

@@ -0,0 +1,18 @@
<!-- 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

@@ -0,0 +1,77 @@
// ============================================================================
// Task Progress Bar — Tactical Dark Theme Styling
// Per CUB-44: Uses --color-primary for bar fill and --color-surface-light
// for track background, mapped to the Control Center's M3 dark tokens.
// ============================================================================
// ---------------------------------------------------------------------------
// Container
// ---------------------------------------------------------------------------
.task-progress-bar {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
// ---------------------------------------------------------------------------
// Info row: percentage label + elapsed time
// ---------------------------------------------------------------------------
.task-progress-bar__info {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.task-progress-bar__percent {
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
font-size: 14px;
font-weight: 600;
color: var(--cc-on-surface, #E2E8F0);
letter-spacing: 0.02em;
}
.task-progress-bar__elapsed {
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
font-size: 12px;
font-weight: 400;
color: var(--cc-on-surface-variant, #8A9BB0);
letter-spacing: 0.01em;
}
// ---------------------------------------------------------------------------
// Material Progress Bar Overrides
// ---------------------------------------------------------------------------
// Map the spec's --color-primary and --color-surface-light to the Control
// Center's actual theme tokens. This ensures the bar uses the tactical dark
// palette while respecting the spec's variable naming.
// ---------------------------------------------------------------------------
.task-progress-bar__bar {
// Override the track (background) to use the surface container
--mat-progress-bar-track-height: 6px;
--mat-progress-bar-active-indicator-height: 6px;
// Bar fill color: primary (cyan/sky blue per tactical dark theme)
--mat-progress-bar-active-indicator-color: var(--color-primary, var(--mat-sys-primary, #38BDF8));
// Track background: surface container (dark slate)
--mat-progress-bar-track-color: var(--color-surface-light, var(--cc-surface-container, #1C2027));
// Border radius for a softer bar
border-radius: 3px;
// Smooth transition on value changes
transition: none;
}
// Rounded ends on the progress bar fill
:host ::ng-deep .mdc-linear-progress__bar-inner {
border-radius: 3px;
}
// Rounded track background
:host ::ng-deep .mdc-linear-progress__track {
border-radius: 3px;
}

View File

@@ -0,0 +1,109 @@
// ============================================================================
// Task Progress Bar Component
// Per CUB-44: Determinate progress bar with optional elapsed time display.
// Uses Angular Material mat-progress-bar in determinate mode with tactical
// dark theme styling via CSS custom properties.
// ============================================================================
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatProgressBarModule } from '@angular/material/progress-bar';
/**
* Displays a determinate progress bar with an optional elapsed time indicator.
*
* Usage:
* ```html
* <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,7 +1,8 @@
// ============================================================================
// Bottom Navigation Bar — Mobile Navigation
// Per spec Section 3.2: M3 NavigationBar pattern
// Visible only on compact breakpoint (< 600px)
// Per CUB-27 spec breakpoints:
// Compact (0599px): Visible — M3 NavigationBar pattern
// Medium+ (≥600px): Hidden — nav rail takes over
// ============================================================================
.bottom-nav {
@@ -17,6 +18,8 @@
align-items: center;
justify-content: space-around;
padding: 0 8px;
// Safe area inset for notched devices
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.bottom-nav__item {
@@ -68,9 +71,24 @@
white-space: nowrap;
}
// Show bottom nav only on compact breakpoint
// ---------------------------------------------------------------------------
// Compact (0599px): Show bottom nav
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.bottom-nav {
display: flex;
}
}
// ---------------------------------------------------------------------------
// Medium+ (≥600px): Hidden — nav rail takes over
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.bottom-nav__item {
transition: none;
}
}

View File

@@ -2,6 +2,16 @@
<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"

View File

@@ -1,36 +1,43 @@
// ============================================================================
// Header Bar — Top App Bar
// Per spec Section 3.1: 64px tall, M3 MediumTopAppBar on expanded
// Section 3.2: SmallTopAppBar on mobile
// Per CUB-27 spec breakpoints:
// Compact (0599px): SmallTopAppBar — 56px height, compact title, hidden labels
// Medium (6001023px): Medium top bar — 64px height
// Expanded (≥1024px): MediumTopAppBar — 64px height, full actions
// ============================================================================
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--cc-header-height);
padding: 0 var(--cc-section-padding);
height: var(--cc-header-height-compact); // Compact by default (mobile-first)
padding: 0 var(--cc-section-padding-compact);
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;
font-size: 20px;
font-weight: 500;
color: var(--cc-on-surface);
margin: 0;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-bar__actions {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
}
.header-bar__action-btn {
color: var(--cc-on-surface-variant) !important;
min-width: 48px;
min-height: 48px;
&:hover {
color: var(--cc-on-surface) !important;
@@ -59,18 +66,61 @@
vertical-align: middle;
}
// Mobile: smaller title
// ---------------------------------------------------------------------------
// Compact (0599px): SmallTopAppBar — hide live label, tighter spacing
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.header-bar__live-label {
display: none; // Space saving on compact — dot alone is enough
}
.header-bar__actions {
gap: 0;
}
}
// ---------------------------------------------------------------------------
// Medium (6001023px): Medium top bar
// ---------------------------------------------------------------------------
@media (min-width: 600px) and (max-width: 1023px) {
.header-bar {
padding: 0 16px;
height: var(--cc-header-height);
padding: 0 var(--cc-section-padding);
}
.header-bar__title {
font-size: 22px;
font-weight: 500;
font-size: 24px;
}
.header-bar__live-label {
display: none; // Space saving on mobile — dot alone is enough
.header-bar__actions {
gap: 4px;
}
}
// ---------------------------------------------------------------------------
// Expanded (≥1024px): Full top bar
// ---------------------------------------------------------------------------
@media (min-width: 1024px) {
.header-bar {
height: var(--cc-header-height);
padding: 0 var(--cc-section-padding);
}
.header-bar__title {
font-size: 28px;
font-weight: 400;
}
.header-bar__actions {
gap: 8px;
}
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.header-bar__live-dot--connected {
animation: none;
}
}

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
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';
@@ -17,6 +17,9 @@ import { MatBadgeModule } from '@angular/material/badge';
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);

View File

@@ -4,7 +4,7 @@
<div class="layout-shell__main">
<!-- Header bar at top of content area -->
<app-header-bar class="layout-shell__header" />
<app-header-bar class="layout-shell__header" (openQuickJump)="openQuickJump()" />
<!-- Scrollable content area -->
<main class="layout-shell__content">
@@ -15,3 +15,6 @@
<!-- 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,7 +1,9 @@
// ============================================================================
// Layout Shell — Adaptive layout container
// Desktop: Nav Rail (left) + Main Content (right)
// Mobile: Header + Content + Bottom Nav (stacked)
// Per CUB-27 spec breakpoints:
// Compact (0599px): Header + Content + Bottom Nav (stacked)
// Medium (6001023px): Collapsed Nav Rail + Header + Content
// Expanded (≥1024px): Expandable Nav Rail + Header + Content
// ============================================================================
.layout-shell {
@@ -37,21 +39,35 @@
flex-shrink: 0;
}
// Mobile: Stack layout vertically, add bottom padding for bottom nav
// ---------------------------------------------------------------------------
// Compact (0599px): Stack layout vertically, bottom nav visible
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.layout-shell {
flex-direction: column;
}
.layout-shell__content {
padding: var(--cc-section-padding-compact);
// Account for bottom nav bar height
padding-bottom: calc(var(--cc-bottom-nav-height) + 16px);
}
}
// Tablet: Ensure content padding accommodates collapsed nav rail
// ---------------------------------------------------------------------------
// Medium (6001023px): Sidebar + content, collapsed nav rail
// ---------------------------------------------------------------------------
@media (min-width: 600px) and (max-width: 1023px) {
.layout-shell__content {
padding: 20px;
}
}
// ---------------------------------------------------------------------------
// Expanded (≥1024px): Full nav rail with expandable behavior
// ---------------------------------------------------------------------------
@media (min-width: 1024px) {
.layout-shell__content {
padding: var(--cc-section-padding);
}
}

View File

@@ -1,8 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, HostListener, ViewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NavRailComponent } from '../nav-rail/nav-rail.component';
import { BottomNavComponent } from '../bottom-nav/bottom-nav.component';
import { HeaderBarComponent } from '../header-bar/header-bar.component';
import { QuickJumpDrawerComponent } from '../../components/quick-jump-drawer/index';
/**
* Layout Shell — wraps the main content area with adaptive navigation.
@@ -13,9 +14,25 @@ import { HeaderBarComponent } from '../header-bar/header-bar.component';
@Component({
selector: 'app-layout-shell',
standalone: true,
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent],
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent],
templateUrl: './layout-shell.component.html',
styleUrl: './layout-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LayoutShellComponent {}
export class LayoutShellComponent {
@ViewChild(QuickJumpDrawerComponent) quickJumpDrawer!: QuickJumpDrawerComponent;
/** Open the quick-jump drawer from anywhere in the layout. */
openQuickJump(): void {
this.quickJumpDrawer?.open();
}
/** Global keyboard shortcut: Ctrl+K or Cmd+K opens the quick-jump drawer. */
@HostListener('document:keydown', ['$event'])
onGlobalKeydown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
this.quickJumpDrawer?.toggle();
}
}
}

View File

@@ -1,11 +1,14 @@
// ============================================================================
// Nav Rail — Desktop/Kiosk Navigation
// Per spec Section 3.1: 72px collapsed / 256px expanded
// Per CUB-27 spec breakpoints:
// Compact (0599px): Hidden — bottom nav takes over
// Medium (6001023px): Collapsed (72px), icon-only
// Expanded (≥1024px): Expandable (72px collapsed / 256px expanded on hover)
// Section 5.4: Spacing & Grid
// ============================================================================
.nav-rail {
display: flex;
display: none; // Hidden by default (mobile-first)
flex-direction: column;
width: var(--cc-nav-rail-collapsed-width);
min-height: 100vh;
@@ -104,9 +107,52 @@
text-overflow: ellipsis;
}
// Responsive: Hide nav rail on mobile (bottom nav takes over)
@media (max-width: 599px) {
// ---------------------------------------------------------------------------
// Medium (6001023px): Show collapsed nav rail (icon-only)
// ---------------------------------------------------------------------------
@media (min-width: 600px) and (max-width: 1023px) {
.nav-rail {
display: flex;
width: var(--cc-nav-rail-collapsed-width);
}
// Always collapsed on medium — hide labels
.nav-rail__brand,
.nav-rail__label {
display: none;
}
.nav-rail__header {
justify-content: center;
padding: 16px 0;
}
.nav-rail__item {
justify-content: center;
padding: 0;
margin: 2px 8px;
}
// Disable expand on medium
.nav-rail--expanded {
width: var(--cc-nav-rail-collapsed-width);
}
}
// ---------------------------------------------------------------------------
// Expanded (≥1024px): Full expandable nav rail
// ---------------------------------------------------------------------------
@media (min-width: 1024px) {
.nav-rail {
display: flex;
}
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.nav-rail {
transition: none;
}
}

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, signal, HostListener } from '@angular/core';
import { ChangeDetectionStrategy, Component, HostListener, signal, OnDestroy, OnInit } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatBadgeModule } from '@angular/material/badge';
@@ -12,21 +12,52 @@ import { NAV_DESTINATIONS } from '../../models/nav.model';
styleUrl: './nav-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavRailComponent {
export class NavRailComponent implements OnInit, OnDestroy {
protected readonly destinations = NAV_DESTINATIONS;
protected readonly expanded = signal(false);
protected readonly isExpandedBreakpoint = signal(false);
private readonly EXPANDED_BP = 1024;
ngOnInit(): void {
this.updateBreakpoint();
}
@HostListener('window:resize')
onResize(): void {
this.updateBreakpoint();
}
@HostListener('mouseenter')
onHoverIn(): void {
if (this.isExpandedBreakpoint()) {
this.expanded.set(true);
}
}
@HostListener('mouseleave')
onHoverOut(): void {
if (this.isExpandedBreakpoint()) {
this.expanded.set(false);
}
}
toggleExpand(): void {
if (this.isExpandedBreakpoint()) {
this.expanded.update(v => !v);
}
}
private updateBreakpoint(): void {
const isExpanded = window.innerWidth >= this.EXPANDED_BP;
this.isExpandedBreakpoint.set(isExpanded);
// Collapse when leaving expanded breakpoint
if (!isExpanded) {
this.expanded.set(false);
}
}
ngOnDestroy(): void {
// Cleanup is handled by HostListener auto-unsubscribe
}
}

View File

@@ -0,0 +1,49 @@
<!-- ========================================================================== -->
<!-- Hub Page — Responsive Agent Card Grid with Filter Chips -->
<!-- Per CUB-27 spec breakpoints: -->
<!-- Compact (0599px): Single-column cards, horizontal-scroll filter chips -->
<!-- Medium (6001023px): 2-column grid -->
<!-- Expanded (≥1024px): 3+ column auto-fill grid -->
<!-- ========================================================================== -->
<div class="hub-page">
<!-- Filter Chip Group — horizontal scroll on mobile -->
<div class="hub-page__filters" role="tablist" aria-label="Filter agents by status">
@for (filter of filters; track filter.value) {
<button
class="hub-page__filter-chip"
[class.hub-page__filter-chip--active]="activeFilter() === filter.value"
role="tab"
[attr.aria-selected]="activeFilter() === filter.value"
(click)="selectFilter(filter.value)"
>
{{ filter.label }}
</button>
}
</div>
<!-- Agent Card Grid -->
<div class="hub-page__grid">
@for (agent of filteredAgents(); track agent.agentId) {
<app-agent-card
[status]="agent.status"
[displayName]="agent.displayName"
[agentId]="agent.agentId"
[role]="agent.role"
[task]="agent.task"
[progress]="agent.progress"
[sessionKey]="agent.sessionKey"
[channel]="agent.channel"
[lastActivity]="agent.lastActivity"
[errorMessage]="agent.errorMessage ?? ''"
/>
}
</div>
<!-- Empty state -->
@if (filteredAgents().length === 0) {
<div class="hub-page__empty">
<p>No agents match the selected filter.</p>
</div>
}
</div>

View File

@@ -0,0 +1,131 @@
// ============================================================================
// Hub Page — Responsive AgentCard Grid with Filter Chips
// Per CUB-27 spec breakpoints:
// Compact (0599px): Single-column cards, horizontal-scroll filter chips
// Medium (6001023px): 2-column grid
// Expanded (≥1024px): 3+ column auto-fill grid
// ============================================================================
.hub-page {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 400px;
overflow-x: hidden;
}
// ---------------------------------------------------------------------------
// Filter Chip Group
// ---------------------------------------------------------------------------
.hub-page__filters {
display: flex;
gap: 8px;
padding: 4px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; // Firefox
&::-webkit-scrollbar {
display: none; // Chrome/Safari
}
}
.hub-page__filter-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
min-width: 48px; // Touch target
padding: 6px 16px;
border: 1px solid var(--cc-outline);
border-radius: 20px;
background-color: transparent;
color: var(--cc-on-surface-variant);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
white-space: nowrap;
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
flex-shrink: 0; // Prevent shrinking in scroll container
&:hover {
background-color: rgba(255, 255, 255, 0.06);
color: var(--cc-on-surface);
}
&:focus-visible {
outline: 2px solid var(--status-active);
outline-offset: 2px;
}
&--active {
background-color: var(--status-active-bg);
color: var(--status-active);
border-color: var(--status-active);
}
}
// ---------------------------------------------------------------------------
// Agent Card Grid
// ---------------------------------------------------------------------------
.hub-page__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--cc-card-gap);
}
// ---------------------------------------------------------------------------
// Empty State
// ---------------------------------------------------------------------------
.hub-page__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--cc-on-surface-variant);
font-size: 16px;
text-align: center;
}
// ---------------------------------------------------------------------------
// Compact (0599px): Single-column cards
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.hub-page {
padding: 0;
}
.hub-page__grid {
grid-template-columns: 1fr;
gap: 12px;
}
.hub-page__filters {
padding: 4px 0 8px;
// Ensure horizontal scroll on mobile
margin: 0 -8px;
padding-left: 8px;
padding-right: 8px;
}
}
// ---------------------------------------------------------------------------
// Medium (6001023px): 2-column grid
// ---------------------------------------------------------------------------
@media (min-width: 600px) and (max-width: 1023px) {
.hub-page__grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--cc-card-gap);
}
}
// ---------------------------------------------------------------------------
// Expanded (≥1024px): 3+ column auto-fill grid
// ---------------------------------------------------------------------------
@media (min-width: 1024px) {
.hub-page__grid {
grid-template-columns: repeat(auto-fill, minmax(var(--cc-card-min-width), 1fr));
gap: var(--cc-card-gap);
}
}

View File

@@ -1,26 +1,113 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core';
import { MatChipsModule } from '@angular/material/chips';
import { CommonModule } from '@angular/common';
import { AgentCardComponent } from '../../command-hub/components/agent-card/agent-card.component';
import { AgentStatus } from '../../models/agent.model';
/**
* Filter options for the hub page agent card grid.
* Per CUB-27: "Filter chip group (All, Active, Error, etc.) with horizontal scroll on mobile"
*/
export type AgentFilter = 'all' | AgentStatus;
@Component({
selector: 'app-hub-page',
standalone: true,
imports: [],
template: `
<div class="hub-page">
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
</div>
`,
styles: [`
.hub-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.hub-page__placeholder {
color: var(--cc-on-surface-variant);
font-size: 16px;
}
`],
imports: [CommonModule, MatChipsModule, AgentCardComponent],
templateUrl: './hub-page.component.html',
styleUrl: './hub-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HubPageComponent {}
export class HubPageComponent {
protected readonly filters: { label: string; value: AgentFilter }[] = [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Idle', value: 'idle' },
{ label: 'Thinking', value: 'thinking' },
{ label: 'Error', value: 'error' },
{ label: 'Offline', value: 'offline' },
];
protected readonly activeFilter = signal<AgentFilter>('all');
/** Stub agent data for demonstrating the responsive grid layout */
protected readonly agents = signal([
{
status: 'active' as AgentStatus,
displayName: 'Otto',
agentId: 'otto',
role: 'Orchestrator',
task: 'Reviewing PR #42',
progress: 65,
sessionKey: 'otto-main',
channel: 'slack',
lastActivity: new Date(),
},
{
status: 'thinking' as AgentStatus,
displayName: 'Rex',
agentId: 'rex',
role: 'Frontend Engineer',
task: 'Building responsive layout',
progress: 40,
sessionKey: 'rex-task-27',
channel: 'webchat',
lastActivity: new Date(Date.now() - 30000),
},
{
status: 'idle' as AgentStatus,
displayName: 'Dex',
agentId: 'dex',
role: 'Backend Engineer',
task: '',
progress: 0,
sessionKey: 'dex-standby',
channel: 'slack',
lastActivity: new Date(Date.now() - 300000),
},
{
status: 'error' as AgentStatus,
displayName: 'Hex',
agentId: 'hex',
role: 'Database Engineer',
task: 'Migration failed',
errorMessage: 'Connection timeout to primary DB',
progress: 0,
sessionKey: 'hex-err-db1',
channel: 'telegram',
lastActivity: new Date(Date.now() - 120000),
},
{
status: 'offline' as AgentStatus,
displayName: 'Pip',
agentId: 'pip',
role: 'Pi Engineer',
task: '',
progress: 0,
sessionKey: 'pip-offline',
channel: 'webchat',
lastActivity: new Date(Date.now() - 3600000),
},
{
status: 'active' as AgentStatus,
displayName: 'Nano',
agentId: 'nano',
role: 'Firmware Engineer',
task: 'Flashing ESP32 build #18',
progress: 80,
sessionKey: 'nano-flash-18',
channel: 'webchat',
lastActivity: new Date(),
},
]);
protected readonly filteredAgents = computed(() => {
const filter = this.activeFilter();
if (filter === 'all') return this.agents();
return this.agents().filter(a => a.status === filter);
});
protected selectFilter(filter: AgentFilter): void {
this.activeFilter.set(filter);
}
}

View File

@@ -48,6 +48,17 @@ html {
// These are NOT part of the M3 tonal palette; they are semantic overrides.
// ---------------------------------------------------------------------------
:root {
// --- Tactical Dark Mode color palette (CUB-47) ---
--color-surface: #0F172A;
--color-surface-light: #1E293B;
--color-primary: #38BDF8;
--color-secondary: #2DD4BF;
--color-accent: #A78BFA;
--color-danger: #F87171;
--color-text-primary: #FFFFFF;
--color-text-secondary: #94A3B8;
--color-border: #334155;
// --- Status colors ---
--status-active: #38BDF8;
--status-idle: #2DD4BF;
@@ -77,20 +88,27 @@ html {
--cc-nav-rail-collapsed-width: 72px;
--cc-nav-rail-expanded-width: 256px;
--cc-header-height: 64px;
--cc-header-height-compact: 56px;
--cc-bottom-nav-height: 80px;
--cc-card-border-radius: 16px;
--cc-card-min-width: 320px;
--cc-card-min-width: 280px;
--cc-card-gap: 16px;
--cc-card-padding: 20px;
--cc-section-padding: 24px;
--cc-section-padding-compact: 16px;
--cc-spacing-unit: 8px;
// --- Responsive breakpoints (CUB-27) ---
--cc-breakpoint-compact: 599px; // 0-599px: phone / compact
--cc-breakpoint-medium: 600px; // 600-1023px: tablet / medium
--cc-breakpoint-expanded: 1024px; // ≥1024px: desktop/kiosk / expanded
}
// ---------------------------------------------------------------------------
// Global Body Styles
// ---------------------------------------------------------------------------
body {
background-color: var(--cc-background);
background-color: var(--color-surface);
color: var(--cc-on-surface);
font-family: 'Inter', 'Roboto', sans-serif;
margin: 0;