Compare commits

...

61 Commits

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

45
.env.example Normal file
View File

@@ -0,0 +1,45 @@
# Control Center - Environment Variables
# ======================================
# ── Backend Variables ───────────────────────────────────────────────────
# Server configuration
PORT=8080
CORS_ORIGIN=http://localhost:3000
LOG_LEVEL=info
ENVIRONMENT=development
# Database connection (PostgreSQL DSN)
# Format: postgresql://user:password@host:port/database?sslmode=disable
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
# Gateway (OpenClaw) connection
# URL to the OpenClaw gateway API for polling agent states
GATEWAY_URL=http://localhost:18789/api/agents
# Polling interval for agent state updates
GATEWAY_POLL_INTERVAL=5s
# ── Frontend Variables (via Vite) ───────────────────────────────────────
# The Vite config exposes these as import.meta.env.VITE_*
# Set via environment variable when building: VITE_API_URL
# VITE_API_URL=http://localhost:8080
# ── Docker Compose Specific ─────────────────────────────────────────────
# When using docker-compose, these are set in the services section
# See docker-compose.yml for service-specific environment variables
# ── Database Configuration ─────────────────────────────────────────────
# Set in the db service environment section of docker-compose.yml
# POSTGRES_USER=controlcenter
# POSTGRES_PASSWORD=controlcenter
# POSTGRES_DB=controlcenter
# ── Development Notes ───────────────────────────────────────────────────
# For local development without Docker:
# 1. Start PostgreSQL locally
# 2. Run: go run ./cmd/server/main.go
# 3. Run: npm run dev in frontend/
#
# For Docker deployment:
# 1. Copy .env.example to .env (backend only)
# 2. Run: docker compose up -d
# 3. Access frontend at http://localhost:3000

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

268
README-deployment.md Normal file
View File

@@ -0,0 +1,268 @@
# Control Center Deployment Guide
This document covers the Docker Compose deployment and kiosk configuration for the Control Center Go + React application.
## Quick Start
```bash
# Start all services (backend, frontend, database)
docker compose up -d
# View logs
docker compose logs -f
# Stop all services
docker compose down
# Stop and remove volumes (database data)
docker compose down -v
```
## Architecture
```
┌─────────────────┐
│ Frontend │ Port 3000 (host) → 80 (container)
│ React + nginx │ Serves SPA, proxies /api/ to backend
└────────┬────────┘
│ HTTP
┌────────▼────────┐
│ Backend │ Port 8080 (host) → 8080 (container)
│ Go HTTP API │ PostgreSQL-backed REST API
└────────┬────────┘
│ PostgreSQL
┌────────▼────────┐
│ PostgreSQL │ Port 5432 (internal only)
│ Database │ Persistent volume at /var/lib/postgresql/data
└─────────────────┘
```
## Services
### Backend (`go-backend`)
- **Image**: Custom `alpine:latest` with Go binary
- **Port**: 8080
- **Build**: Multi-stage from `go-backend/Dockerfile`
- **Environment Variables**:
- `PORT` (default: 8080)
- `DATABASE_URL` (PostgreSQL DSN)
- `CORS_ORIGIN` (default: `*`)
- `LOG_LEVEL` (default: `info`)
- `ENVIRONMENT` (default: `development`)
- `GATEWAY_URL` (OpenClaw gateway endpoint)
### Frontend (`frontend`)
- **Image**: `nginx:1.27-alpine`
- **Port**: 80 (internal) → 3000 (host)
- **Build**: Multi-stage from `frontend/Dockerfile`
- Node 22 for build
- Nginx 1.27 for serving
- **Config**: Custom nginx config in `frontend/nginx.conf`
- **Environment Variables**:
- `VITE_API_URL` (passed at build time via Vite config)
### Database (`db`)
- **Image**: `postgres:16-alpine`
- **Port**: 5432 (internal only)
- **Volume**: `postgres-data:/var/lib/postgresql/data`
- **Environment Variables**:
- `POSTGRES_USER` (default: `controlcenter`)
- `POSTGRES_PASSWORD` (default: `controlcenter`)
- `POSTGRES_DB` (default: `controlcenter`)
## Kiosk Mode
For dedicated display installations (e.g., control center dashboard), Chromium can run in kiosk mode.
### Installation
1. **Install the systemd service** (on Debian/Ubuntu with systemd):
```bash
sudo cp kiosk/control-center-kiosk.service /etc/systemd/system/
sudo systemctl daemon-reload
```
2. **Enable auto-start**:
```bash
sudo systemctl enable control-center-kiosk
```
3. **Start the service**:
```bash
sudo systemctl start control-center-kiosk
```
4. **Check status and logs**:
```bash
sudo systemctl status control-center-kiosk
sudo journalctl -u control-center-kiosk -f
```
### Manual Launch
```bash
# From project root
./kiosk/start-kiosk.sh http://localhost:3000
```
### Uninstall
```bash
# Stop and disable service
sudo systemctl stop control-center-kiosk
sudo systemctl disable control-center-kiosk
sudo rm /etc/systemd/system/control-center-kiosk.service
sudo systemctl daemon-reload
```
### Kiosk Requirements
- **Browser**: `chromium-browser` (install via `apt-get install chromium`)
- **Display**: X11 session with `DISPLAY=:0`
- **User**: Must run as a user with X11 access (typically `overseer`)
- **Permissions**: Read access to the project directory
## Environment Variables Reference
### Backend (`go-backend/.env`)
```bash
PORT=8080
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
CORS_ORIGIN=*
LOG_LEVEL=info
ENVIRONMENT=development
GATEWAY_URL=http://localhost:18789/api/agents
GATEWAY_POLL_INTERVAL=5s
```
### Frontend (build-time)
```bash
VITE_API_URL=http://localhost:8080
```
### Docker Compose
Set via `services.<name>.environment` in `docker-compose.yml`:
```yaml
services:
backend:
environment:
- DATABASE_URL=...
frontend:
environment:
- VITE_API_URL=...
db:
environment:
- POSTGRES_USER=...
- POSTGRES_PASSWORD=...
- POSTGRES_DB=...
```
## Development
### Local Development (non-Docker)
```bash
# Backend
cd go-backend
go run ./cmd/server/main.go
# Frontend
cd frontend
npm install
npm run dev
```
### Database Migrations
```bash
# If using pgx/migrate or similar
# The database is created automatically on first connection if it doesn't exist
```
## Troubleshooting
### Backend won't connect to database
```bash
# Check database container status
docker compose ps
# View database logs
docker compose logs db
# Test database connectivity from backend
docker compose exec backend ping db
```
### Frontend can't reach backend
```bash
# Check network connectivity
docker compose exec frontend ping backend
# Verify backend is running
docker compose logs backend
```
### Kiosk browser won't start
```bash
# Check Chromium installation
which chromium-browser
# Check X11 forwarding
echo $DISPLAY
# Manual launch for debugging
./kiosk/start-kiosk.sh http://localhost:3000
```
### Port conflicts
If ports 8080, 3000, or 5432 are already in use, modify `docker-compose.yml`:
```yaml
services:
backend:
ports:
- "8081:8080" # Change host port
frontend:
ports:
- "3001:80" # Change host port
```
## Production Considerations
1. **HTTPS**: Add a reverse proxy (nginx/Traefik) for SSL termination
2. **Database security**: Use strong passwords, enable SSL
3. **CORS**: Restrict `CORS_ORIGIN` to production domain
4. **Logs**: Configure log aggregation (e.g., ELK, Loki)
5. **Backups**: Regular PostgreSQL volume backups
6. **Monitoring**: Add health checks and alerting
## Files
| File/Directory | Purpose |
|----------------|---------|
| `docker-compose.yml` | Service definitions and configuration |
| `.env.example` | Environment variable template |
| `go-backend/Dockerfile` | Backend build definition |
| `frontend/Dockerfile` | Frontend build definition |
| `frontend/nginx.conf` | Nginx config for SPA routing |
| `kiosk/start-kiosk.sh` | Kiosk browser startup script |
| `kiosk/control-center-kiosk.service` | Systemd unit for auto-start |

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,13 +6,19 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<!-- Exclude the separate ControlCenter sub-project from this project's compilation -->
<ItemGroup>
<Compile Remove="ControlCenter/**/*.cs" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -37,6 +37,33 @@ public class AgentStatusHub : Hub<IAgentStatusClient>
_logger = logger; _logger = logger;
} }
/// <summary>
/// Broadcasts an agent status update to all connected clients.
///
/// <para>
/// Any connected client (or server-side caller) can invoke this method
/// to push a status update to every subscriber. The DTO is converted to
/// an <see cref="AgentStatusUpdate"/> record and relayed through the
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
/// </para>
/// </summary>
/// <param name="update">The agent status update DTO to broadcast.</param>
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
{
_logger.LogInformation(
"Broadcasting status update for agent {AgentId}: {Status}",
update.AgentId, update.Status);
var agentUpdate = update.ToUpdate();
// Broadcast to all connected clients
await Clients.All.AgentStatusChanged(agentUpdate);
// Also push to the specific agent's group
var agentGroup = AgentGroupName(update.AgentId);
await Clients.Group(agentGroup).AgentStatusChanged(agentUpdate);
}
/// <summary> /// <summary>
/// Adds the calling connection to the fleet group. /// Adds the calling connection to the fleet group.
/// Once joined, the client will receive all agent status changes /// Once joined, the client will receive all agent status changes

View File

@@ -72,6 +72,80 @@ public record TaskProgressUpdate(
string? Elapsed 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> /// <summary>
/// Snapshot of an agent's full card data, sent on initial connection /// Snapshot of an agent's full card data, sent on initial connection
/// or when the fleet state is requested. /// 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.AddSingleton<GatewayEventBridgeService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<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(); var app = builder.Build();
// ── Middleware ────────────────────────────────────────────── // ── 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.Data;
using ControlCenter.Api.Hubs; using ControlCenter.Api.Hubs;
using ControlCenter.Api.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -10,6 +11,9 @@ builder.Services.AddOpenApi();
// Register SignalR for real-time agent status updates // Register SignalR for real-time agent status updates
builder.Services.AddSignalR(); builder.Services.AddSignalR();
// Register Agent State Repository
builder.Services.AddScoped<IAgentStateRepository, AgentStateRepository>();
// Register DbContext with PostgreSQL // Register DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options => 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

@@ -0,0 +1,106 @@
// Package models defines the database entities for the Control Center Go backend.
// Structs map 1:1 to the PostgreSQL schema defined in backend/migrations/.
package models
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// AgentStatus represents the possible lifecycle states of an agent.
type AgentStatus string
const (
AgentStatusActive AgentStatus = "active"
AgentStatusIdle AgentStatus = "idle"
AgentStatusThinking AgentStatus = "thinking"
AgentStatusError AgentStatus = "error"
AgentStatusOffline AgentStatus = "offline"
)
// Agent represents a registered agent and its current state.
type Agent struct {
ID pgtype.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Status AgentStatus `db:"status" json:"status"`
Task *string `db:"task" json:"task,omitempty"`
Progress int32 `db:"progress" json:"progress"`
SessionKey *string `db:"session_key" json:"session_key,omitempty"`
Channel *string `db:"channel" json:"channel,omitempty"`
LastActivity time.Time `db:"last_activity" json:"last_activity"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// SessionStatus represents the possible states of an agent session.
type SessionStatus string
const (
SessionStatusRunning SessionStatus = "running"
SessionStatusCompleted SessionStatus = "completed"
SessionStatusCrashed SessionStatus = "crashed"
SessionStatusTerminated SessionStatus = "terminated"
)
// Session tracks an agent session over time.
type Session struct {
ID pgtype.UUID `db:"id" json:"id"`
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
StartedAt time.Time `db:"started_at" json:"started_at"`
EndedAt *time.Time `db:"ended_at" json:"ended_at,omitempty"`
Status SessionStatus `db:"status" json:"status"`
}
// TaskLogStatus represents the possible states of a task log entry.
type TaskLogStatus string
const (
TaskLogStatusPending TaskLogStatus = "pending"
TaskLogStatusRunning TaskLogStatus = "running"
TaskLogStatusCompleted TaskLogStatus = "completed"
TaskLogStatusFailed TaskLogStatus = "failed"
TaskLogStatusCancelled TaskLogStatus = "cancelled"
)
// TaskLog records a historical task assigned to an agent.
type TaskLog struct {
ID pgtype.UUID `db:"id" json:"id"`
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
Task string `db:"task" json:"task"`
Status TaskLogStatus `db:"status" json:"status"`
StartedAt time.Time `db:"started_at" json:"started_at"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
ErrorMessage *string `db:"error_message" json:"error_message,omitempty"`
}
// ProjectStatus represents the possible states of a project.
type ProjectStatus string
const (
ProjectStatusPlanned ProjectStatus = "planned"
ProjectStatusInProgress ProjectStatus = "in_progress"
ProjectStatusCompleted ProjectStatus = "completed"
ProjectStatusPaused ProjectStatus = "paused"
ProjectStatusCancelled ProjectStatus = "cancelled"
)
// Project represents a project managed by the Control Center.
type Project struct {
ID pgtype.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
Status ProjectStatus `db:"status" json:"status"`
AgentID *pgtype.UUID `db:"agent_id" json:"agent_id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// AgentEvent represents an event in the agent lifecycle or telemetry stream.
type AgentEvent struct {
ID pgtype.UUID `db:"id" json:"id"`
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
EventType string `db:"event_type" json:"event_type"`
Payload *map[string]interface{} `db:"payload" json:"payload,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View File

@@ -0,0 +1,9 @@
-- Migration: 001_initial_schema (down)
-- Description: Reverts the core Control Center database schema.
-- Drop in reverse dependency order to avoid FK conflicts
DROP TABLE IF EXISTS agent_events;
DROP TABLE IF EXISTS task_logs;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS agents;

View File

@@ -0,0 +1,97 @@
-- Migration: 001_initial_schema
-- Description: Creates the core Control Center database schema.
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- Table: agents
-- ============================================
CREATE TABLE agents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'idle'
CHECK (status IN ('active', 'idle', 'thinking', 'error', 'offline')),
task TEXT,
progress INT NOT NULL DEFAULT 0
CHECK (progress >= 0 AND progress <= 100),
session_key TEXT,
channel TEXT,
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE agents IS 'Registered agents and their current state';
COMMENT ON COLUMN agents.status IS 'Agent lifecycle status: active, idle, thinking, error, offline';
-- ============================================
-- Table: sessions
-- ============================================
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agent_id UUID NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'completed', 'crashed', 'terminated')),
CONSTRAINT fk_sessions_agent
FOREIGN KEY (agent_id) REFERENCES agents(id)
ON DELETE CASCADE
);
COMMENT ON TABLE sessions IS 'Agent session history';
-- ============================================
-- Table: task_logs
-- ============================================
CREATE TABLE task_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agent_id UUID NOT NULL,
task TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
error_message TEXT,
CONSTRAINT fk_task_logs_agent
FOREIGN KEY (agent_id) REFERENCES agents(id)
ON DELETE CASCADE
);
COMMENT ON TABLE task_logs IS 'Historical record of tasks assigned to agents';
-- ============================================
-- Table: projects
-- ============================================
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'in_progress', 'completed', 'paused', 'cancelled')),
agent_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_projects_agent
FOREIGN KEY (agent_id) REFERENCES agents(id)
ON DELETE SET NULL
);
COMMENT ON TABLE projects IS 'Projects managed by the Control Center';
-- ============================================
-- Table: agent_events
-- ============================================
CREATE TABLE agent_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
agent_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_agent_events_agent
FOREIGN KEY (agent_id) REFERENCES agents(id)
ON DELETE CASCADE
);
COMMENT ON TABLE agent_events IS 'Event stream for agent lifecycle and telemetry';

View File

@@ -0,0 +1,20 @@
-- Migration: 002_add_indexes (down)
-- Description: Remove all indexes added in 002_add_indexes.
DROP INDEX IF EXISTS idx_agents_status;
DROP INDEX IF EXISTS idx_agents_last_activity;
DROP INDEX IF EXISTS idx_agents_created_at;
DROP INDEX IF EXISTS idx_sessions_agent_id;
DROP INDEX IF EXISTS idx_sessions_status;
DROP INDEX IF EXISTS idx_sessions_started_at;
DROP INDEX IF EXISTS idx_task_logs_agent_started;
DROP INDEX IF EXISTS idx_task_logs_status;
DROP INDEX IF EXISTS idx_agent_events_agent_created;
DROP INDEX IF EXISTS idx_agent_events_event_type;
DROP INDEX IF EXISTS idx_projects_status;
DROP INDEX IF EXISTS idx_projects_agent_id;
DROP INDEX IF EXISTS idx_projects_created;

View File

@@ -0,0 +1,25 @@
-- Migration: 002_add_indexes
-- Description: Add performance indexes for common query patterns.
-- agents: status filtering, activity ordering
CREATE INDEX idx_agents_status ON agents(status);
CREATE INDEX idx_agents_last_activity ON agents(last_activity DESC);
CREATE INDEX idx_agents_created_at ON agents(created_at DESC);
-- sessions: agent session lookups, active session checks
CREATE INDEX idx_sessions_agent_id ON sessions(agent_id);
CREATE INDEX idx_sessions_status ON sessions(status);
CREATE INDEX idx_sessions_started_at ON sessions(started_at DESC);
-- task_logs: agent task history, chronological ordering
CREATE INDEX idx_task_logs_agent_started ON task_logs(agent_id, started_at DESC);
CREATE INDEX idx_task_logs_status ON task_logs(status);
-- agent_events: event stream queries
CREATE INDEX idx_agent_events_agent_created ON agent_events(agent_id, created_at DESC);
CREATE INDEX idx_agent_events_event_type ON agent_events(event_type);
-- projects: status filtering, agent assignment
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_agent_id ON projects(agent_id);
CREATE INDEX idx_projects_created ON projects(created_at DESC);

72
docker-compose.yml Normal file
View File

@@ -0,0 +1,72 @@
# Control Center - Go + React + PostgreSQL Deployment
# ============================================================
services:
# ── Backend Service (Go) ───────────────────────────────────────────────
backend:
build:
context: ./go-backend
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgresql://controlcenter:controlcenter@db:5432/controlcenter?sslmode=disable
- CORS_ORIGIN=http://localhost:3000
- LOG_LEVEL=info
- ENVIRONMENT=production
- PORT=8080
- GATEWAY_URL=http://host.docker.internal:18789/api/agents
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- control-center-network
restart: unless-stopped
# ── Frontend Service (React) ───────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:80"
depends_on:
- backend
environment:
- VITE_API_URL=http://localhost:8080
networks:
- control-center-network
restart: unless-stopped
# ── Database Service (PostgreSQL 16) ───────────────────────────────────
db:
image: postgres:16-alpine
container_name: control-center-db
environment:
- POSTGRES_USER=controlcenter
- POSTGRES_PASSWORD=controlcenter
- POSTGRES_DB=controlcenter
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U controlcenter -d controlcenter"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- control-center-network
restart: unless-stopped
networks:
control-center-network:
driver: bridge
volumes:
postgres-data:

View File

@@ -0,0 +1,33 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-error.log
# Build output (rebuilt in container)
dist/
out-tsc/
# Angular cache
.angular/cache/
# IDE
.idea/
.vscode/
*.sublime-workspace
# OS files
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Docker
Dockerfile
.dockerignore
# Misc
coverage/
tmp/
*.log

44
frontend-legacy/.gitignore vendored Normal file
View File

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

View File

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

59
frontend-legacy/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Frontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -53,7 +53,13 @@
], ],
"styles": [ "styles": [
"src/styles.scss" "src/styles.scss"
] ],
"stylePreprocessorOptions": {
"includePaths": [
"src",
"src/styles"
]
}
}, },
"configurations": { "configurations": {
"production": { "production": {

View File

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

8034
frontend-legacy/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.11.0",
"dependencies": {
"@angular/animations": "^21.2.10",
"@angular/cdk": "^21.2.8",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.8",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@microsoft/signalr": "^10.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.8",
"@angular/cli": "^21.2.8",
"@angular/compiler-cli": "^21.2.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
}
}

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
<router-outlet />

View File

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

View File

@@ -0,0 +1,100 @@
<!-- ========================================================================== -->
<!-- AgentCard — per spec Section 7.3 -->
<!-- Integrates: Status Badge · Task Progress Bar · QuickJump Button -->
<!-- Leftborder accent matches status color. role="article" + arialabels. -->
<!-- Enhanced: data-status attribute, elapsed time, design tokens. -->
<!-- ========================================================================== -->
<article
class="agent-card"
role="article"
[attr.data-status]="status"
[attr.aria-label]="(displayName || agentId) + ' ' + statusLabel()"
[style.border-left-color]="statusBorderColor()"
(click)="cardClick.emit(sessionKey)"
appLongPress
[appLongPressDuration]="500"
(appLongPress)="cardLongPress.emit(sessionKey)"
tabindex="0"
>
<!-- ── 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 || isError()">
<p
class="agent-card__task"
[class.agent-card__task--error]="isError()"
[attr.aria-label]="'Current task: ' + (isError() ? errorMessage || task : task)"
>
{{ isError() ? errorMessage || task : task }}
</p>
</div>
<!-- ── Task Progress Bar ── -->
<div
class="agent-card__progress"
*ngIf="showProgress()"
[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>
<!-- ── Elapsed Time ── -->
<div
class="agent-card__elapsed"
*ngIf="taskElapsed && isActiveLike()"
[attr.aria-label]="'Elapsed: ' + taskElapsed"
>
<mat-icon aria-hidden="true" class="agent-card__elapsed-icon">schedule</mat-icon>
<span class="text-mono">{{ taskElapsed }}</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,267 @@
// ============================================================================
// AgentCard — M3 tactical dark styling
// Per spec Section 7.3: leftborder accent, statusaware coloring,
// responsive card layout with 320px minwidth.
// Enhanced: data-status selectors, elapsed time, design token imports.
// ============================================================================
@use 'tokens' as tokens;
.agent-card {
display: flex;
flex-direction: column;
min-width: tokens.$cc-card-min-width;
padding: tokens.$cc-card-padding;
background-color: var(--cc-surface-container);
border-radius: tokens.$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);
}
// CUB-26: Card is now clickable for session drawer
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&: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);
}
.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 {
color: var(--status-error);
}
}
// ── Progress Bar ──
.agent-card__progress {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.agent-card__progress-label {
font-family: var(--cc-font-mono);
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);
}
}
// ── Elapsed Time ──
.agent-card__elapsed {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--cc-on-surface-variant);
padding: 2px 0;
}
.agent-card__elapsed-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: var(--status-thinking);
}
// ── 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 .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;
.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 ──
// Using data-status attribute selectors for clean styling.
.agent-card[data-status="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);
}
.agent-card[data-status="offline"] .agent-card__badge {
background-color: var(--cc-surface-container-high);
}
// ── Activelike pulse on card border ──
.agent-card[data-status="active"],
.agent-card[data-status="thinking"] {
border-left-width: 4px;
}
.agent-card[data-status="error"] {
border-left-color: var(--status-error);
}
// ── Responsive ──
@media (max-width: tokens.$cc-breakpoint-mobile) {
.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,183 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
Output,
computed,
effect,
inject,
signal,
} 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';
import { LongPressDirective } from '../../../directives/long-press.directive';
import {
STATUS_COLORS,
STATUS_LABELS,
CHANNEL_ICONS,
} from '../../../design/tokens';
// ============================================================================
// AgentCard Component
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
// and QuickJump Button into a card with leftborder status accent.
// CUB-26: Emits cardClick and cardLongPress for drawer/modal integration.
// Enhanced with data-status attribute, elapsed time, and design tokens.
// ============================================================================
@Component({
selector: 'app-agent-card',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
MatTooltipModule,
LongPressDirective,
],
templateUrl: './agent-card.component.html',
styleUrl: './agent-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentCardComponent implements OnDestroy {
// --- 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 = '';
/** Elapsed time string, e.g. "04m 12s" */
@Input() taskElapsed = '';
// --- CUB-26: Outputs for drawer/modal integration ---
/** Emitted when the card is clicked — opens the session drawer. */
@Output() readonly cardClick = new EventEmitter<string>();
/** Emitted when the card is long-pressed — bypasses drawer, opens session log directly. */
@Output() readonly cardLongPress = new EventEmitter<string>();
// --- Internal state ---
/** Timer for refreshing relative-time label */
private _timer: ReturnType<typeof setInterval> | null = null;
/** Internal signal to trigger relative-time recomputation */
private readonly _tick = signal(0);
// --- Computed values using design tokens ---
/** 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 (from design tokens) */
readonly statusLabel = computed(() => STATUS_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 (from design tokens) */
readonly channelIcon = computed(() => CHANNEL_ICONS[this.channel] ?? 'chat');
/** Relative time string for lastActivity, refreshed every 30s */
readonly lastActivityLabel = computed(() => {
// Read tick to create dependency that forces recomputation
this._tick();
return this._relativeTime(this.lastActivity);
});
/** Quickjump route derived from sessionKey */
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
/** Whether progress bar should show */
readonly showProgress = computed(() => this.progress > 0 && this.status !== 'error');
/** Whether error state is active */
readonly isError = computed(() => this.status === 'error');
/** Whether card is in an active-like state (active or thinking) */
readonly isActiveLike = computed(() => this.status === 'active' || this.status === 'thinking');
constructor() {
// Start the relative-time refresh timer
this._startTimer();
}
ngOnDestroy(): void {
this._stopTimer();
}
// --- Private helpers ---
private _relativeTime(date: Date | null | undefined): string {
if (!date) return '';
const now = Date.now();
const then = date.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`;
}
private _startTimer(): void {
this._stopTimer();
this._timer = setInterval(() => {
// Increment tick to force lastActivityLabel recomputation
this._tick.update(v => v + 1);
}, 30_000);
}
private _stopTimer(): void {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
}
}

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,140 @@
<!-- ============================================================================
Agent Session Drawer — CUB-26
Desktop: 480px side drawer slides from right with modal overlay.
Mobile: Bottom sheet slides up from bottom.
Shows: Agent name, status badge, session key, live log tail,
recent messages, and action buttons.
============================================================================-->
<!-- Backdrop overlay -->
@if (isOpen()) {
<div
class="session-drawer-backdrop"
(click)="onBackdropClick()"
[class.session-drawer-backdrop--visible]="isOpen()"
></div>
}
<!-- Drawer panel -->
<div
#drawerPanel
class="session-drawer"
[class.session-drawer--open]="isOpen()"
[class.session-drawer--mobile]="isMobile"
(keydown)="onDrawerKeydown($event)"
role="dialog"
aria-label="Agent session details"
[attr.aria-hidden]="!isOpen()"
>
<!-- Header -->
<div class="session-drawer__header">
@if (agent) {
<div class="session-drawer__header-identity">
<span class="status-dot {{ getStatusClass(agent.status) }}" [attr.aria-label]="getStatusLabel(agent.status)"></span>
<div class="session-drawer__header-text">
<h2 class="session-drawer__title">{{ agent.displayName }}</h2>
<span class="session-drawer__role">{{ agent.role }}</span>
</div>
</div>
}
<button
class="session-drawer__close-btn"
type="button"
aria-label="Close drawer"
(click)="close()"
matIconButton
>
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Content area -->
@if (agent) {
<div class="session-drawer__content">
<!-- Status & Session Key Section -->
<section class="session-drawer__section">
<div class="session-drawer__meta-row">
<span class="session-drawer__status-chip {{ getStatusChipColor(agent.status) }}">
{{ getStatusLabel(agent.status) }}
</span>
@if (agent.channel) {
<span class="session-drawer__channel-badge">
<mat-icon class="session-drawer__channel-icon">chat</mat-icon>
{{ agent.channel }}
</span>
}
</div>
<div class="session-drawer__session-key">
<span class="session-drawer__label">Session Key</span>
<code class="session-drawer__key-value">{{ agent.sessionKey }}</code>
</div>
@if (agent.currentTask) {
<div class="session-drawer__task-info">
<span class="session-drawer__label">Current Task</span>
<span class="session-drawer__task-text">{{ agent.currentTask }}</span>
</div>
}
<div class="session-drawer__last-activity">
<span class="session-drawer__label">Last Activity</span>
<span class="session-drawer__activity-time">{{ formatRelativeTime(agent.lastActivity) }}</span>
</div>
</section>
<!-- Recent Messages Section -->
<section class="session-drawer__section">
<h3 class="session-drawer__section-title">Recent Messages</h3>
<div class="session-drawer__messages">
@for (msg of recentMessages(); track msg.id) {
<div class="session-drawer__message session-drawer__message--{{ msg.sender }}">
<span class="session-drawer__message-sender">
{{ msg.sender === 'agent' ? agent.displayName : 'You' }}
</span>
<p class="session-drawer__message-text">{{ msg.content }}</p>
<span class="session-drawer__message-time">{{ formatTime(msg.timestamp) }}</span>
</div>
} @empty {
<p class="session-drawer__empty-state">No recent messages</p>
}
</div>
</section>
<!-- Live Log Tail Section -->
<section class="session-drawer__section">
<h3 class="session-drawer__section-title">Live Log</h3>
<div class="session-drawer__log-container">
@for (line of logLines(); track $index) {
<div class="session-drawer__log-line {{ getLogLevelClass(line.level) }}">
<span class="session-drawer__log-time">{{ formatTime(line.timestamp) }}</span>
<span class="session-drawer__log-level">{{ line.level.toUpperCase() }}</span>
<span class="session-drawer__log-message">{{ line.message }}</span>
</div>
} @empty {
<p class="session-drawer__empty-state">No log output</p>
}
</div>
</section>
</div>
}
<!-- Action buttons (sticky footer) -->
<div class="session-drawer__actions">
<button
class="session-drawer__action-btn session-drawer__action-btn--primary"
mat-raised-button
color="primary"
(click)="onOpenSession()"
>
<mat-icon>open_in_new</mat-icon>
Open Full Session
</button>
<button
class="session-drawer__action-btn session-drawer__action-btn--secondary"
mat-stroked-button
(click)="onPinToDashboard()"
>
<mat-icon>push_pin</mat-icon>
Pin to Dashboard
</button>
</div>
</div>

View File

@@ -0,0 +1,500 @@
// ============================================================================
// Agent Session Drawer — CUB-26
// Desktop: 480px side drawer slides from right with modal overlay.
// Mobile: Bottom sheet slides up from bottom.
// Uses Control Center design tokens from CUB-21.
// ============================================================================
// ---------------------------------------------------------------------------
// Backdrop
// ---------------------------------------------------------------------------
.session-drawer-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 998;
opacity: 0;
transition: opacity 200ms ease-out;
&--visible {
opacity: 1;
}
}
// ---------------------------------------------------------------------------
// Drawer Panel — Desktop: Side drawer from right
// ---------------------------------------------------------------------------
.session-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 480px;
max-width: 100vw;
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 280ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4);
overflow: hidden;
&--open {
transform: translateX(0);
}
// ---------------------------------------------------------------------------
// Mobile: Bottom Sheet — slides up from bottom
// ---------------------------------------------------------------------------
&--mobile {
top: auto;
right: 0;
bottom: 0;
left: 0;
width: 100%;
max-width: 100vw;
max-height: 85vh;
border-left: none;
border-top: 1px solid var(--cc-outline);
border-radius: 20px 20px 0 0;
transform: translateY(100%);
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
&.session-drawer--open {
transform: translateY(0);
}
// Drag handle for mobile bottom sheet
&::before {
content: '';
display: block;
width: 32px;
height: 4px;
border-radius: 2px;
background: var(--cc-on-surface-variant);
opacity: 0.4;
margin: 8px auto 4px;
}
}
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
.session-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--cc-outline);
flex-shrink: 0;
}
.session-drawer__header-identity {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.session-drawer__header-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.session-drawer__title {
font-size: 20px;
font-weight: 600;
color: var(--cc-on-surface);
margin: 0;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-drawer__role {
font-size: 13px;
color: var(--cc-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-drawer__close-btn {
--mat-icon-button-state-layer-color: transparent;
color: var(--cc-on-surface-variant);
flex-shrink: 0;
&:hover {
color: var(--cc-on-surface);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
// ---------------------------------------------------------------------------
// Content — scrollable area
// ---------------------------------------------------------------------------
.session-drawer__content {
flex: 1;
overflow-y: auto;
padding: 0 24px 16px;
}
// ---------------------------------------------------------------------------
// Sections
// ---------------------------------------------------------------------------
.session-drawer__section {
padding: 16px 0;
border-bottom: 1px solid var(--cc-outline);
&:last-child {
border-bottom: none;
}
}
.session-drawer__section-title {
font-size: 13px;
font-weight: 600;
color: var(--cc-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 12px;
}
// ---------------------------------------------------------------------------
// Meta Row — Status + Channel
// ---------------------------------------------------------------------------
.session-drawer__meta-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.session-drawer__status-chip {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
&.status-chip--active {
background-color: var(--status-active-bg);
color: var(--status-active);
}
&.status-chip--idle {
background-color: var(--status-idle-bg);
color: var(--status-idle);
}
&.status-chip--thinking {
background-color: var(--status-thinking-bg);
color: var(--status-thinking);
}
&.status-chip--error {
background-color: var(--status-error-bg);
color: var(--status-error);
}
&.status-chip--offline {
background-color: rgba(100, 116, 139, 0.12);
color: var(--status-offline);
}
}
.session-drawer__channel-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 8px;
background: var(--cc-surface-container-high);
font-size: 12px;
color: var(--cc-on-surface-variant);
}
.session-drawer__channel-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
// ---------------------------------------------------------------------------
// Session Key
// ---------------------------------------------------------------------------
.session-drawer__session-key {
margin-bottom: 12px;
}
.session-drawer__label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--cc-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 4px;
}
.session-drawer__key-value {
display: block;
font-family: var(--cc-font-mono);
font-size: 12px;
color: var(--color-secondary);
background: var(--cc-surface);
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--cc-outline);
word-break: break-all;
line-height: 1.5;
user-select: all;
}
// ---------------------------------------------------------------------------
// Task Info
// ---------------------------------------------------------------------------
.session-drawer__task-info {
margin-bottom: 12px;
}
.session-drawer__task-text {
font-size: 14px;
color: var(--cc-on-surface);
line-height: 1.4;
}
// ---------------------------------------------------------------------------
// Last Activity
// ---------------------------------------------------------------------------
.session-drawer__last-activity {
margin-bottom: 0;
}
.session-drawer__activity-time {
font-size: 14px;
color: var(--cc-on-surface);
}
// ---------------------------------------------------------------------------
// Recent Messages
// ---------------------------------------------------------------------------
.session-drawer__messages {
display: flex;
flex-direction: column;
gap: 10px;
}
.session-drawer__message {
padding: 10px 14px;
border-radius: 12px;
max-width: 100%;
&--agent {
background: var(--cc-surface-container-high);
border: 1px solid var(--cc-outline);
}
&--user {
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.15);
}
}
.session-drawer__message-sender {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--cc-on-surface-variant);
margin-bottom: 4px;
}
.session-drawer__message-text {
font-size: 14px;
color: var(--cc-on-surface);
margin: 0;
line-height: 1.5;
word-wrap: break-word;
}
.session-drawer__message-time {
display: block;
font-size: 11px;
color: var(--cc-on-surface-variant);
margin-top: 4px;
font-family: var(--cc-font-mono);
}
// ---------------------------------------------------------------------------
// Live Log Container
// ---------------------------------------------------------------------------
.session-drawer__log-container {
background: var(--cc-surface);
border: 1px solid var(--cc-outline);
border-radius: 10px;
padding: 10px 12px;
max-height: 260px;
overflow-y: auto;
font-family: var(--cc-font-mono);
font-size: 12px;
line-height: 1.7;
}
.session-drawer__log-line {
display: flex;
gap: 8px;
padding: 1px 0;
white-space: nowrap;
&--info {
color: var(--cc-on-surface);
}
&--warn {
color: #FBBF24;
}
&--error {
color: var(--status-error);
}
&--debug {
color: var(--cc-on-surface-variant);
}
}
.session-drawer__log-time {
color: var(--cc-on-surface-variant);
flex-shrink: 0;
opacity: 0.7;
}
.session-drawer__log-level {
width: 48px;
flex-shrink: 0;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.02em;
}
.session-drawer__log-message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
}
// ---------------------------------------------------------------------------
// Empty State
// ---------------------------------------------------------------------------
.session-drawer__empty-state {
font-size: 13px;
color: var(--cc-on-surface-variant);
text-align: center;
padding: 16px 0;
margin: 0;
}
// ---------------------------------------------------------------------------
// Action Buttons — Sticky Footer
// ---------------------------------------------------------------------------
.session-drawer__actions {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px 20px;
border-top: 1px solid var(--cc-outline);
background-color: var(--cc-surface-container);
flex-shrink: 0;
}
.session-drawer__action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
.mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
&--primary {
flex: 1;
}
&--secondary {
flex-shrink: 0;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
// ---------------------------------------------------------------------------
// Mobile Adjustments
// ---------------------------------------------------------------------------
.session-drawer--mobile {
.session-drawer__header {
padding: 12px 20px 12px;
}
.session-drawer__content {
padding: 0 20px 12px;
}
.session-drawer__actions {
padding: 12px 20px 16px;
flex-direction: column;
.session-drawer__action-btn {
width: 100%;
justify-content: center;
}
}
.session-drawer__log-container {
max-height: 200px;
}
}
// ---------------------------------------------------------------------------
// Responsive — wider viewports keep 480px, narrow go full-width
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.session-drawer:not(.session-drawer--mobile) {
width: 100%;
max-width: 100vw;
}
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.session-drawer {
transition: none;
}
.session-drawer-backdrop {
transition: none;
}
}

View File

@@ -0,0 +1,268 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnDestroy,
Output,
signal,
ViewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { AgentCardData, AgentStatus } from '../../models/agent.model';
// ============================================================================
// Agent Session Drawer — Per CUB-26
// Desktop: 480px side drawer slides from right with modal overlay.
// Mobile: Bottom sheet slides up from bottom.
// Shows: Agent name, status badge, session key, live log tail,
// recent messages, and action buttons.
// ============================================================================
export interface SessionLogLine {
timestamp: Date;
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
}
export interface SessionMessage {
id: string;
sender: 'agent' | 'user';
content: string;
timestamp: Date;
}
@Component({
selector: 'app-agent-session-drawer',
standalone: true,
imports: [CommonModule, MatButtonModule, MatIconModule, MatChipsModule],
templateUrl: './agent-session-drawer.component.html',
styleUrl: './agent-session-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentSessionDrawerComponent implements OnDestroy {
// ---------------------------------------------------------------------------
// Inputs
// ---------------------------------------------------------------------------
/** The agent whose session details are displayed. */
@Input() set agent(value: AgentCardData | null) {
this._agent = value;
if (value) {
this.isOpen.set(true);
this.loadSessionData(value);
}
}
get agent(): AgentCardData | null {
return this._agent;
}
private _agent: AgentCardData | null = null;
/** Whether this is mobile viewport (bottom sheet mode). */
@Input() isMobile = false;
// ---------------------------------------------------------------------------
// Outputs
// ---------------------------------------------------------------------------
/** Emitted when the user clicks "Open Full Session". Payload is the session key. */
@Output() readonly openSession = new EventEmitter<string>();
/** Emitted when the user clicks "Pin to Dashboard". Payload is the session key. */
@Output() readonly pinToDashboard = new EventEmitter<string>();
/** Emitted when the drawer closes. */
@Output() readonly drawerClose = new EventEmitter<void>();
// ---------------------------------------------------------------------------
// Signals
// ---------------------------------------------------------------------------
readonly isOpen = signal(false);
readonly logLines = signal<SessionLogLine[]>([]);
readonly recentMessages = signal<SessionMessage[]>([]);
// ---------------------------------------------------------------------------
// View Children
// ---------------------------------------------------------------------------
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
// ---------------------------------------------------------------------------
// Status Helpers
// ---------------------------------------------------------------------------
getStatusClass(status: string): string {
return `status-dot--${status}`;
}
getStatusLabel(status: AgentStatus): string {
const labels: Record<AgentStatus, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking…',
error: 'Error',
offline: 'Offline',
};
return labels[status] ?? status;
}
getStatusChipColor(status: AgentStatus): string {
const map: Record<AgentStatus, string> = {
active: 'status-chip--active',
idle: 'status-chip--idle',
thinking: 'status-chip--thinking',
error: 'status-chip--error',
offline: 'status-chip--offline',
};
return map[status] ?? '';
}
getLogLevelClass(level: SessionLogLine['level']): string {
return `log-line--${level}`;
}
/** Format a date to a short time string. */
formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
/** Format a date to a relative time string. */
formatRelativeTime(date: Date): string {
const now = Date.now();
const then = date.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`;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Open the drawer for a specific agent. */
open(agentData: AgentCardData): void {
this._agent = agentData;
this.isOpen.set(true);
this.loadSessionData(agentData);
}
/** Close the drawer. */
close(): void {
this.isOpen.set(false);
this.drawerClose.emit();
}
// ---------------------------------------------------------------------------
// Keyboard Handling
// ---------------------------------------------------------------------------
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.isOpen()) {
this.close();
}
}
/** Handle keyboard navigation within the drawer. */
onDrawerKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.close();
return;
}
// Tab through actions — browser default Tab behavior is fine,
// we just trap focus within the drawer
}
// ---------------------------------------------------------------------------
// Outside Click
// ---------------------------------------------------------------------------
onBackdropClick(): void {
this.close();
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
onOpenSession(): void {
if (this._agent) {
this.openSession.emit(this._agent.sessionKey);
}
this.close();
}
onPinToDashboard(): void {
if (this._agent) {
this.pinToDashboard.emit(this._agent.sessionKey);
}
// Don't close — user may want to keep viewing
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
ngOnDestroy(): void {
// Clean up any subscriptions when needed
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
/** Load mock session data for the agent (TODO: wire to real data service). */
private loadSessionData(agentData: AgentCardData): void {
// TODO: Replace with real session data service when available.
// For now, generate placeholder log lines and messages.
const now = new Date();
const logLines: SessionLogLine[] = [];
for (let i = 19; i >= 0; i--) {
const ts = new Date(now.getTime() - i * 5000);
const levels: SessionLogLine['level'][] = ['info', 'info', 'info', 'debug', 'warn'];
const messages = [
`Processing task queue for ${agentData.displayName}`,
`SignalR heartbeat OK`,
`Session state: active`,
`Checking for pending commands…`,
`Updating task progress: ${Math.floor(Math.random() * 100)}%`,
];
logLines.push({
timestamp: ts,
level: levels[i % levels.length],
message: messages[i % messages.length],
});
}
this.logLines.set(logLines);
const recentMessages: SessionMessage[] = [
{
id: '1',
sender: 'user',
content: `Hey ${agentData.displayName}, how's the current task going?`,
timestamp: new Date(now.getTime() - 120000),
},
{
id: '2',
sender: 'agent',
content: agentData.currentTask ?? 'Working on it — progress is steady.',
timestamp: new Date(now.getTime() - 115000),
},
{
id: '3',
sender: 'user',
content: 'Great, let me know if you hit any blockers.',
timestamp: new Date(now.getTime() - 110000),
},
];
this.recentMessages.set(recentMessages);
}
}

View File

@@ -0,0 +1,2 @@
export { AgentSessionDrawerComponent } from './agent-session-drawer.component';
export type { SessionLogLine, SessionMessage } from './agent-session-drawer.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

@@ -0,0 +1,5 @@
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';
export { AgentSessionDrawerComponent } from './agent-session-drawer/index';
export type { SessionLogLine, SessionMessage } from './agent-session-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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { LongPressDirective } from './long-press.directive';

View File

@@ -0,0 +1,89 @@
import {
Directive,
ElementRef,
EventEmitter,
OnDestroy,
Output,
Input,
} from '@angular/core';
// ============================================================================
// Long-Press Directive — CUB-26
// Emits after a sustained press (500ms default).
// Used on agent cards to bypass the drawer and open Session Log directly.
// ============================================================================
@Directive({
selector: '[appLongPress]',
standalone: true,
host: {
'(mousedown)': 'onMouseDown($event)',
'(mouseup)': 'onMouseUp()',
'(mouseleave)': 'onMouseLeave()',
'(touchstart)': 'onTouchStart($event)',
'(touchend)': 'onTouchEnd()',
'(touchmove)': 'onTouchMove()',
'(contextmenu)': 'onContextMenu($event)',
},
})
export class LongPressDirective implements OnDestroy {
/** Duration in ms before a press counts as a long press. */
@Input() appLongPressDuration = 500;
/** Emits when a long press is detected. Payload is the original event. */
@Output() readonly appLongPress = new EventEmitter<MouseEvent | TouchEvent>();
private timer: ReturnType<typeof setTimeout> | null = null;
private isLongPress = false;
onMouseDown(event: MouseEvent): void {
this.isLongPress = false;
this.timer = setTimeout(() => {
this.isLongPress = true;
this.appLongPress.emit(event);
}, this.appLongPressDuration);
}
onMouseUp(): void {
this.clearTimer();
}
onMouseLeave(): void {
this.clearTimer();
}
onTouchStart(event: TouchEvent): void {
this.isLongPress = false;
this.timer = setTimeout(() => {
this.isLongPress = true;
this.appLongPress.emit(event);
}, this.appLongPressDuration);
}
onTouchEnd(): void {
this.clearTimer();
}
onTouchMove(): void {
// Cancel on touch move (finger moved)
this.clearTimer();
}
onContextMenu(event: MouseEvent): void {
// Prevent native context menu on long press
if (this.isLongPress) {
event.preventDefault();
}
}
ngOnDestroy(): void {
this.clearTimer();
}
private clearTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

View File

@@ -1,7 +1,8 @@
// ============================================================================ // ============================================================================
// Bottom Navigation Bar Mobile Navigation // Bottom Navigation Bar Mobile Navigation
// Per spec Section 3.2: M3 NavigationBar pattern // Per CUB-27 spec breakpoints:
// Visible only on compact breakpoint (< 600px) // Compact (0599px): Visible M3 NavigationBar pattern
// Medium+ (600px): Hidden nav rail takes over
// ============================================================================ // ============================================================================
.bottom-nav { .bottom-nav {
@@ -17,6 +18,8 @@
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;
padding: 0 8px; padding: 0 8px;
// Safe area inset for notched devices
padding-bottom: env(safe-area-inset-bottom, 0px);
} }
.bottom-nav__item { .bottom-nav__item {
@@ -68,9 +71,24 @@
white-space: nowrap; white-space: nowrap;
} }
// Show bottom nav only on compact breakpoint // ---------------------------------------------------------------------------
// Compact (0599px): Show bottom nav
// ---------------------------------------------------------------------------
@media (max-width: 599px) { @media (max-width: 599px) {
.bottom-nav { .bottom-nav {
display: flex; 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> <h1 class="header-bar__title">Command Hub</h1>
<div class="header-bar__actions"> <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 --> <!-- Live indicator -->
<button <button
class="header-bar__action-btn header-bar__live-btn" class="header-bar__action-btn header-bar__live-btn"

View File

@@ -0,0 +1,126 @@
// ============================================================================
// Header Bar — Top App Bar
// 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-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: 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: 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;
}
}
.header-bar__live-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
background-color: var(--status-error);
vertical-align: middle;
&--connected {
background-color: var(--status-active);
animation: pulse-active 2s ease-in-out infinite;
}
}
.header-bar__live-label {
font-size: 13px;
font-weight: 500;
color: var(--cc-on-surface-variant);
vertical-align: middle;
}
// ---------------------------------------------------------------------------
// 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 {
height: var(--cc-header-height);
padding: 0 var(--cc-section-padding);
}
.header-bar__title {
font-size: 24px;
}
.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 { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
@@ -17,6 +17,9 @@ import { MatBadgeModule } from '@angular/material/badge';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class HeaderBarComponent { 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 notificationCount = signal(3);
protected readonly isConnected = signal(true); protected readonly isConnected = signal(true);

View File

@@ -4,7 +4,7 @@
<div class="layout-shell__main"> <div class="layout-shell__main">
<!-- Header bar at top of content area --> <!-- 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 --> <!-- Scrollable content area -->
<main class="layout-shell__content"> <main class="layout-shell__content">
@@ -14,4 +14,14 @@
<!-- Mobile: Bottom Navigation Bar --> <!-- Mobile: Bottom Navigation Bar -->
<app-bottom-nav class="layout-shell__bottom-nav" /> <app-bottom-nav class="layout-shell__bottom-nav" />
</div> </div>
<!-- Quick-Jump Drawer (global overlay) -->
<app-quick-jump-drawer />
<!-- Agent Session Drawer (CUB-26) — desktop: side drawer, mobile: bottom sheet -->
<app-agent-session-drawer
[isMobile]="isMobile()"
(openSession)="onOpenSession($event)"
(pinToDashboard)="onPinToDashboard($event)"
/>

View File

@@ -0,0 +1,73 @@
// ============================================================================
// Layout Shell — Adaptive layout container
// 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 {
display: flex;
min-height: 100vh;
background-color: var(--cc-background);
}
.layout-shell__nav-rail {
flex-shrink: 0;
}
.layout-shell__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; // Prevent flex overflow
overflow: hidden;
}
.layout-shell__header {
flex-shrink: 0;
}
.layout-shell__content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--cc-section-padding);
}
.layout-shell__bottom-nav {
flex-shrink: 0;
}
// ---------------------------------------------------------------------------
// 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);
}
}
// ---------------------------------------------------------------------------
// 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

@@ -0,0 +1,85 @@
import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, signal, ViewChild } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Subscription } from 'rxjs';
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';
import { AgentSessionDrawerComponent } from '../../components/agent-session-drawer/index';
import { AgentCardData } from '../../models/agent.model';
/**
* Layout Shell — wraps the main content area with adaptive navigation.
* Desktop/Kiosk: Nav Rail (left) + Header + Content
* Mobile: Header + Content + Bottom Nav
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
* CUB-26: Hosts the Agent Session Drawer for quick-jump navigation.
*/
@Component({
selector: 'app-layout-shell',
standalone: true,
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent, AgentSessionDrawerComponent],
templateUrl: './layout-shell.component.html',
styleUrl: './layout-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LayoutShellComponent implements OnDestroy {
@ViewChild(QuickJumpDrawerComponent) quickJumpDrawer!: QuickJumpDrawerComponent;
@ViewChild(AgentSessionDrawerComponent) sessionDrawer!: AgentSessionDrawerComponent;
/** Whether the viewport is mobile-sized. */
readonly isMobile = signal(false);
private readonly breakpointSub: Subscription;
constructor(
private readonly breakpointObserver: BreakpointObserver,
private readonly router: Router,
) {
this.breakpointSub = this.breakpointObserver
.observe([Breakpoints.Handset, Breakpoints.Small])
.subscribe((result) => {
this.isMobile.set(result.matches);
});
}
/** Open the quick-jump drawer from anywhere in the layout. */
openQuickJump(): void {
this.quickJumpDrawer?.open();
}
/** Open the session drawer for a specific agent. */
openSessionDrawer(agent: AgentCardData): void {
this.sessionDrawer?.open(agent);
}
/** Open the session log page directly (long-press bypass). */
openSessionLog(sessionKey: string): void {
this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } });
}
/** Handle "Open Full Session" action from session drawer. */
onOpenSession(sessionKey: string): void {
this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } });
}
/** Handle "Pin to Dashboard" action from session drawer. */
onPinToDashboard(sessionKey: string): void {
// TODO: Implement pin-to-dashboard logic
console.log('[LayoutShell] Pin to dashboard:', sessionKey);
}
/** 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();
}
}
ngOnDestroy(): void {
this.breakpointSub.unsubscribe();
}
}

View File

@@ -1,11 +1,14 @@
// ============================================================================ // ============================================================================
// Nav Rail Desktop/Kiosk Navigation // 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 // Section 5.4: Spacing & Grid
// ============================================================================ // ============================================================================
.nav-rail { .nav-rail {
display: flex; display: none; // Hidden by default (mobile-first)
flex-direction: column; flex-direction: column;
width: var(--cc-nav-rail-collapsed-width); width: var(--cc-nav-rail-collapsed-width);
min-height: 100vh; min-height: 100vh;
@@ -104,9 +107,52 @@
text-overflow: ellipsis; 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 { .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; 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

@@ -0,0 +1,63 @@
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';
import { NAV_DESTINATIONS } from '../../models/nav.model';
@Component({
selector: 'app-nav-rail',
standalone: true,
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
templateUrl: './nav-rail.component.html',
styleUrl: './nav-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
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,57 @@
<!-- ========================================================================== -->
<!-- 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 -->
<!-- CUB-26: Integrates AgentCard click/long-press with session drawer. -->
<!-- ========================================================================== -->
<div class="hub-page">
<h1 class="hub-page__title">Command Hub</h1>
<!-- 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.id) {
<app-agent-card
[status]="agent.status"
[task]="agent.currentTask ?? ''"
[progress]="agent.taskProgress ?? 0"
[sessionKey]="agent.sessionKey"
[channel]="agent.channel"
[lastActivity]="agent.lastActivity"
[agentId]="agent.id"
[displayName]="agent.displayName"
[role]="agent.role"
[errorMessage]="agent.errorMessage ?? ''"
(cardClick)="onCardClick($event)"
(cardLongPress)="onCardLongPress($event)"
/>
} @empty {
<p class="hub-page__empty">No agents online</p>
}
</div>
</div>
<!-- Agent Session Drawer (CUB-26) -->
<app-agent-session-drawer
[isMobile]="isMobile()"
(openSession)="onOpenSession($event)"
(pinToDashboard)="onPinToDashboard($event)"
(drawerClose)="onDrawerClose()"
/>

View File

@@ -0,0 +1,141 @@
// ============================================================================
// 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;
padding: var(--cc-section-padding);
}
.hub-page__title {
grid-column: 1 / -1;
font-size: 24px;
font-weight: 600;
color: var(--cc-on-surface);
margin: 0 0 8px;
}
// ---------------------------------------------------------------------------
// 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__placeholder,
.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

@@ -0,0 +1,153 @@
import { ChangeDetectionStrategy, Component, signal, computed, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatChipsModule } from '@angular/material/chips';
import { AgentCardComponent } from '../../command-hub/components/agent-card/agent-card.component';
import { AgentSessionDrawerComponent } from '../../components/agent-session-drawer/index';
import { AgentCardData } from '../../models/agent.model';
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: [CommonModule, MatChipsModule, AgentCardComponent, AgentSessionDrawerComponent],
templateUrl: './hub-page.component.html',
styleUrl: './hub-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HubPageComponent {
@ViewChild(AgentSessionDrawerComponent) sessionDrawer!: AgentSessionDrawerComponent;
readonly isMobile = signal(false);
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 (TODO: wire to AgentStatusService / SignalR). */
readonly agents = signal<AgentCardData[]>([
{
id: 'otto',
displayName: 'Otto',
role: 'Orchestrator Agent',
status: 'active',
currentTask: 'Reviewing PR #42',
taskProgress: 67,
taskElapsed: '04m 12s',
sessionKey: 'agent:otto:slack:CUB-42:abc123',
channel: 'slack',
lastActivity: new Date(),
},
{
id: 'rex',
displayName: 'Rex',
role: 'Frontend Agent',
status: 'thinking',
currentTask: 'Building responsive layout',
taskProgress: 40,
taskElapsed: '02m 30s',
sessionKey: 'agent:rex:telegram:CUB-27:def456',
channel: 'telegram',
lastActivity: new Date(Date.now() - 30000),
},
{
id: 'dex',
displayName: 'Dex',
role: 'Backend Agent',
status: 'idle',
currentTask: undefined,
taskProgress: undefined,
taskElapsed: undefined,
sessionKey: 'agent:dex:slack:CUB-53:ghi789',
channel: 'slack',
lastActivity: new Date(Date.now() - 300000),
},
{
id: 'hex',
displayName: 'Hex',
role: 'Database Agent',
status: 'error',
currentTask: 'Migration failed — rollback initiated',
taskProgress: 0,
taskElapsed: '00m 45s',
sessionKey: 'agent:hex:slack:CUB-56:jkl012',
channel: 'slack',
lastActivity: new Date(Date.now() - 60000),
errorMessage: 'Connection timeout to database server',
},
{
id: 'nano',
displayName: 'Nano',
role: 'ESP32 Agent',
status: 'offline',
currentTask: undefined,
taskProgress: undefined,
taskElapsed: undefined,
sessionKey: 'agent:nano:mqtt:CUB-48:mno345',
channel: 'mqtt',
lastActivity: new Date(Date.now() - 86400000),
},
]);
protected readonly filteredAgents = computed(() => {
const filter = this.activeFilter();
if (filter === 'all') return this.agents();
return this.agents().filter(a => a.status === filter);
});
constructor() {
// Detect mobile viewport
if (typeof window !== 'undefined') {
const mql = window.matchMedia('(max-width: 599px)');
this.isMobile.set(mql.matches);
mql.addEventListener('change', (e) => this.isMobile.set(e.matches));
}
}
protected selectFilter(filter: AgentFilter): void {
this.activeFilter.set(filter);
}
/** Card click → open session drawer with agent details. */
onCardClick(sessionKey: string): void {
const agent = this.agents().find((a) => a.sessionKey === sessionKey);
if (agent) {
this.sessionDrawer?.open(agent);
}
}
/** Long-press on card → bypass drawer, go directly to session log. */
onCardLongPress(sessionKey: string): void {
console.log('[Hub] Long press — navigate to session log:', sessionKey);
// TODO: Navigate directly to session log page when sessions route is implemented
}
/** Open full session from drawer action button. */
onOpenSession(sessionKey: string): void {
console.log('[Hub] Open full session:', sessionKey);
// TODO: Navigate to full session view
}
/** Pin agent to dashboard from drawer action button. */
onPinToDashboard(sessionKey: string): void {
console.log('[Hub] Pin to dashboard:', sessionKey);
// TODO: Implement pin-to-dashboard
}
/** Drawer closed. */
onDrawerClose(): void {
// No-op for now — drawer is self-managing
}
}

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