Compare commits

..

1 Commits

Author SHA1 Message Date
Rex ff9f6fec83 CUB-27: Responsive layout and adaptive navigation
Dev Build / build-test (pull_request) Successful in 2m7s
2026-04-28 08:43:57 -04:00
176 changed files with 6764 additions and 23037 deletions
-50
View File
@@ -1,50 +0,0 @@
# 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
# WebSocket gateway config (primary path)
WS_GATEWAY_URL=ws://host.docker.internal:18789/
# Gateway auth token — same as OPENCLAW_GATEWAY_TOKEN (set in environment)
GATEWAY_TOKEN=
# REST poller config (fallback, only used if WS fails to connect)
GATEWAY_URL=http://host.docker.internal:18789/api/agents
# Polling interval for agent state updates (fallback only)
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
-126
View File
@@ -1,126 +0,0 @@
name: Deploy (Dev)
on:
repository_dispatch:
types:
- dev-build-success
workflow_dispatch:
env:
BINARY_NAME: server
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_BINARY_PATH: /opt/control-center/server
DEPLOY_FRONTEND_PATH: /usr/share/nginx/html
SERVICE_NAME: control-center-server
FRONTEND_SERVICE: nginx
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Download Go binary
uses: actions/download-artifact@v4
with:
name: go-backend-binary
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist
- name: Make binary executable
run: chmod +x ${{ env.BINARY_NAME }}
- name: Generate deploy script
run: |
cat > deploy.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BINARY="${1}"
FRONTEND_DIST="${2:-dist}"
BINARY_PATH="${3:-/opt/control-center/server}"
FRONTEND_PATH="${4:-/usr/share/nginx/html}"
BINARY_SERVICE="${5:-control-center-server}"
FRONTEND_SERVICE="${6:-nginx}"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
BACKUP="${BINARY_PATH}.${TIMESTAMP}.bak"
echo "=== deploy backend ==="
if [ -f "$BINARY_PATH" ]; then
echo "backing up current binary"
cp "$BINARY_PATH" "$BACKUP"
fi
echo "installing new binary"
cp "$BINARY" "$BINARY_PATH"
chmod +x "$BINARY_PATH"
echo "restarting service"
systemctl reload-or-restart "$BINARY_SERVICE" || systemctl restart "$BINARY_SERVICE"
sleep 3
if ! systemctl is-active --quiet "$BINARY_SERVICE"; then
echo "FAILED: $BINARY_SERVICE did not start — rolling back"
if [ -f "$BACKUP" ]; then
cp "$BACKUP" "$BINARY_PATH"
systemctl restart "$BINARY_SERVICE"
fi
exit 1
fi
echo "backend deploy ok — keeping last 3 backups"
ls -t "${BINARY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
echo "=== deploy frontend ==="
if [ -d "$FRONTEND_DIST" ] && [ -n "$(ls -A "$FRONTEND_DIST" 2>/dev/null)" ]; then
rsync -a --delete "$FRONTEND_DIST/" "$FRONTEND_PATH/"
systemctl reload "$FRONTEND_SERVICE" 2>/dev/null ||:
echo "frontend deploy ok"
fi
echo "=== deploy complete ==="
SCRIPT
chmod +x deploy.sh
- name: Copy artifacts to dev server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
source: "${{ env.BINARY_NAME }},deploy.sh,dist"
target: "/tmp/control-center-deploy"
- name: Execute deploy on dev server
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
set -euo pipefail
cd /tmp/control-center-deploy
sudo ./deploy.sh \
"${{ env.BINARY_NAME }}" \
"dist" \
"${{ env.DEPLOY_BINARY_PATH }}" \
"${{ env.DEPLOY_FRONTEND_PATH }}" \
"${{ env.SERVICE_NAME }}" \
"${{ env.FRONTEND_SERVICE }}"
rm -rf /tmp/control-center-deploy
- name: Notify on failure
if: failure()
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
echo "deploy failed — commit ${{ github.sha }}" > /tmp/control-center-deploy-failure.log
+20 -63
View File
@@ -1,4 +1,4 @@
name: Dev Build & Deploy
name: Dev Build
on:
pull_request:
@@ -6,82 +6,39 @@ on:
push:
branches: [dev]
env:
GO_VERSION: "1.23"
NODE_VERSION: "22"
REGISTRY: code.cubecraftcreations.com
BACKEND_IMAGE: ${{ gitea.repository }}/backend
FRONTEND_IMAGE: ${{ gitea.repository }}/frontend
jobs:
test-and-build:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Go
run: |
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -C /usr/local -xz
echo "/usr/local/go/bin" >> $GITHUB_PATH
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Install Node.js
run: |
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | sudo tar -C /usr/local --strip-components=1 -xJ
echo "/usr/local/bin" >> $GITHUB_PATH
- name: Run backend tests
run: go test ./...
working-directory: ./go-backend
- name: Restore backend
run: dotnet restore
working-directory: ./backend
- name: Build backend
run: go build -ldflags="-w -s" -o /tmp/server ./cmd/server
working-directory: ./go-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: Lint frontend
run: npm run lint
working-directory: ./frontend
- name: Build frontend
run: npm run build
working-directory: ./frontend
docker-build-push:
needs: test-and-build
if: gitea.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.ACCESS_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build & push backend image
uses: docker/build-push-action@v6
with:
context: ./go-backend
push: true
tags: |
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:dev
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ gitea.sha }}
- name: Build & push frontend image
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: |
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:dev
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ gitea.sha }}
-268
View File
@@ -1,268 +0,0 @@
# 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 |
-106
View File
@@ -1,106 +0,0 @@
// 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"`
}
@@ -1,9 +0,0 @@
-- 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;
@@ -1,97 +0,0 @@
-- 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';
@@ -1,20 +0,0 @@
-- 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;
-25
View File
@@ -1,25 +0,0 @@
-- 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);
-11
View File
@@ -1,11 +0,0 @@
FROM catthehacker/ubuntu:act-latest
# Install Go 1.23
RUN curl -sL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz
# Install Node 22
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/usr/local/go/bin:${PATH}"
-74
View File
@@ -1,74 +0,0 @@
# 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
- WS_GATEWAY_URL=ws://host.docker.internal:18789/
- GATEWAY_TOKEN=${GATEWAY_TOKEN:-}
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:
-304
View File
@@ -1,304 +0,0 @@
# Control Center — Project Context
> **Last updated:** 2026-05-21
> **Repo:** `CubeCraft-Creations/Control-Center` | **Host:** `code.cubecraftcreations.com`
> **Local clone:** `/mnt/ai-storage/projects/Control-Center` | **Default branch:** `dev`
> **Discord:** `DISCORD_DEV_CONTROL_CENTER_CHANNEL_ID`
> **Linear Epic:** [CUB-119](https://linear.app/cubecraft-creations/issue/CUB-119)
---
## Overview
Real-time dashboard for monitoring and controlling the OpenClaw agent fleet. Displays agent statuses, active tasks, sessions, and projects. Uses SSE for live updates from the Go backend, which connects to the OpenClaw gateway via WebSocket for live agent data.
**Completed refactor:** ASP.NET Core + Angular → Go + React is done (CUB-119 epic). All legacy code is removed from git.
## Tech Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Backend | Go 1.24+ | Chi router, pgx (PostgreSQL), SSE broker, gorilla/websocket |
| Frontend | React 18 + TypeScript | Vite, Tailwind CSS, React Query, TanStack Router |
| Database | PostgreSQL 16+ | snake_case naming, 2 migrations |
| Real-time | SSE + WebSocket | SSE for browser, WebSocket for OpenClaw gateway |
| Gateway Integration | WebSocket client | OpenClaw gateway `/ws` — live agent + session RPC |
| API Client | TypeScript SDK | `api-client/` — shared models, WS client, HTTP client |
| CI/CD | Gitea Actions | `.gitea/workflows/dev.yml`, `deploy-dev.yaml` |
| Deployment | Docker Compose | PostgreSQL + Go backend + React/nginx |
| Testing | Vitest, Go test | Unit + integration tests for WS client, gateway, handlers |
| Design | Kiosk layout | Bottom nav (mobile), nav rail (desktop), quick-jump drawer |
## Architecture
```
OpenClaw Gateway (WebSocket)
Go Backend (Chi + pgx)
├── Gateway WS Client (connect, reconnect, agents.list, sessions.list RPC)
├── SSE Broker (fan-out: agent.status, agent.task, fleet.update)
├── REST API (/api/agents, /api/sessions, /api/tasks, /api/projects)
└── Repository/Store layers → PostgreSQL
├── SSE /api/events
React Frontend
├── SSEProvider → useRealtimeSync → React Query cache
├── HubPage (dashboard), LogsPage, ProjectsPage, SessionsPage, SettingsPage
└── Layout (header bar + nav rail + bottom nav + quick-jump drawer)
```
### Key Architecture Decisions
1. **Go replaced ASP.NET Core** — lighter runtime, faster cold-start, better concurrency for gateway polling
2. **React replaced Angular** — lighter than Angular for dashboard/kiosk use
3. **SSE over SignalR** — simpler server-side, unidirectional events sufficient for browser updates
4. **WebSocket for gateway integration** — bidirectional needed for RPC (agents.list, sessions.list)
5. **PostgreSQL** — shared with Extrudex pattern; migrations in `go-backend/migrations/`
6. **Agent state seeded on first boot** via `gateway.SeedDemoAgents` for offline dev
## Project Structure
```
Control-Center/
├── go-backend/ # Go backend
│ ├── cmd/server/main.go # Entrypoint, wire deps, start gateway poller
│ ├── Dockerfile / go.mod / go.sum
│ ├── migrations/ # 001_initial_schema, 002_add_indexes
│ └── internal/
│ ├── config/config.go # Env vars (DATABASE_URL, GATEWAY_URL, etc.)
│ ├── db/db.go # PostgreSQL pool (pgx)
│ ├── gateway/
│ │ ├── client.go # GW poller → sync DB + SSE fan-out
│ │ ├── events.go # SSE event broker
│ │ ├── events_test.go
│ │ ├── sync.go # Initial sync from gateway
│ │ ├── sync_test.go
│ │ ├── wsclient.go # WebSocket client (handshake, connect, reconnect, RPC)
│ │ └── wsclient_test.go
│ ├── handler/
│ │ ├── agent.go # CRUD + history
│ │ ├── project.go # List projects
│ │ ├── session.go # List sessions
│ │ ├── sse.go # SSE broker: subscribe + broadcast
│ │ ├── task.go # List tasks
│ │ ├── helpers.go
│ │ ├── handler_test.go
│ │ └── mock_repos_test.go
│ ├── models/models.go # Domain types
│ ├── repository/ # DB access layer + interfaces
│ ├── router/router.go # Chi router: REST + SSE mount
│ └── store/ # Agent, Project, Session, Task stores
├── api-client/ # Shared TypeScript SDK
│ └── src/
│ ├── models/types.ts # Agent, Session, Task, Project, SSE event types
│ ├── services/http-client.ts # Axios REST client
│ ├── utils/
│ │ ├── config.ts # Client config
│ │ └── status-mapper.ts # Agent status → display mapping
│ └── websocket/
│ └── ws-client.ts # WebSocket client (handshake, Send, RPC, reconnector)
├── frontend/ # React frontend
│ ├── Dockerfile + nginx.conf
│ ├── package.json + vite.config.ts
│ ├── src/
│ │ ├── App.tsx / main.tsx
│ │ ├── components/
│ │ │ ├── ErrorBoundary.tsx
│ │ │ └── Layout.tsx # Header bar + nav rail + bottom nav + quick-jump
│ │ ├── contexts/
│ │ │ └── SSEContext.tsx # SSEProvider — wraps entire app
│ │ ├── hooks/
│ │ │ ├── useLocalStorage.ts
│ │ │ ├── useRealtimeSync.ts # SSE messages → React Query cache
│ │ │ ├── useRealtimeSync.test.tsx
│ │ │ ├── useSSE.ts # SSE: connect, reconnect, typed events
│ │ │ ├── useSSE.test.ts
│ │ │ └── useTheme.tsx
│ │ ├── pages/
│ │ │ ├── HubPage.tsx # Fleet dashboard (agent grid + stats)
│ │ │ ├── LogsPage.tsx # Agent log viewer
│ │ │ ├── ProjectsPage.tsx # Project list
│ │ │ ├── SessionsPage.tsx # Session list
│ │ │ └── SettingsPage.tsx # Settings + theme toggle
│ │ ├── services/
│ │ │ ├── api.ts # Axios REST client
│ │ │ └── sse.ts # SSE utilities
│ │ └── types/index.ts
│ └── vitest.config.ts
├── frontend-legacy/ # Original Angular frontend (kept for reference, not in git)
├── backend/ # Original ASP.NET backend (kept for reference, not in git)
│ ├── ControlCenter/ # ASP.NET Core project
│ └── Api/ # API layer
├── design/
│ ├── command-hub-spec.md # Detailed design spec
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
├── kiosk/
│ ├── control-center-kiosk.service # Systemd service
│ └── start-kiosk.sh # Kiosk startup script
├── reference/
│ └── CONTROL_CENTER_CONTEXT.md # Older context file (superseded by this one)
├── ci-image/Dockerfile # CI build image
├── docker-compose.yml
└── .env.example
```
## Database Schema (PostgreSQL)
### agents
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| display_name | VARCHAR(256) NOT NULL | |
| role | VARCHAR(256) | |
| status | VARCHAR(32) | active, idle, thinking, error |
| current_task | VARCHAR(512) | |
| task_progress | INTEGER DEFAULT 0 | |
| session_key | VARCHAR(256) | |
| channel | VARCHAR(256) | |
| last_activity | TIMESTAMP | |
| error_message | TEXT | |
| created_at | TIMESTAMP | |
| updated_at | TIMESTAMP | |
### sessions
| Column | Type |
|--------|------|
| id | UUID PK |
| session_key | VARCHAR(256) UNIQUE |
| agent_id | UUID FK → agents |
| channel | VARCHAR(256) |
| status | VARCHAR(32) |
| context_tokens | INTEGER |
| total_tokens | INTEGER |
| estimated_cost | NUMERIC |
| model | VARCHAR(256) |
| started_at | TIMESTAMP |
| last_activity_at | TIMESTAMP |
### tasks
| Column | Type |
|--------|------|
| id | UUID PK |
| agent_id | UUID FK → agents |
| title | VARCHAR(512) |
| description | TEXT |
| status | VARCHAR(32) |
| progress | INTEGER DEFAULT 0 |
| session_key | VARCHAR(256) |
| created_at, updated_at | TIMESTAMP |
### projects
| Column | Type |
|--------|------|
| id | UUID PK |
| name | VARCHAR(256) UNIQUE |
| description | TEXT |
| status | VARCHAR(32) |
| agent_ids | TEXT[] |
| created_at, updated_at | TIMESTAMP |
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /health | Health check (probes DB) |
| GET | /api/agents | List all agents |
| POST | /api/agents | Create agent |
| GET | /api/agents/:id | Get agent detail |
| PUT | /api/agents/:id | Update agent |
| DELETE | /api/agents/:id | Delete agent |
| GET | /api/agents/:id/history | Agent status history |
| GET | /api/sessions | List sessions |
| GET | /api/tasks | List tasks |
| GET | /api/projects | List projects |
| GET | /api/events | SSE event stream |
## SSE Events
| Event Type | Payload |
|------------|---------|
| `agent.status` | Agent status change |
| `agent.task` | Agent current task updated |
| `agent.progress` | Task progress percentage |
| `fleet.update` | Full fleet snapshot |
| `connected` | Connection established |
## CI/CD Pipeline
### dev.yml
- Lint + typecheck: Go vet + golangci-lint + tsc
- Test: Go test + vitest
- Build: Go build + npm build
- Docker: Build and push images
- Triggers: push to dev/main
### deploy-dev.yaml
- Workflow dispatch
- SCP deploy script to dev host
- systemctl restart with rollback
## Docker Compose
| Service | Image/Build | Ports | Depends On |
|---------|-------------|-------|------------|
| postgres | postgres:16-alpine | 5432 | — |
| backend | ./go-backend/Dockerfile | 8080 | postgres (healthy) |
| frontend | ./frontend/Dockerfile | 3000 | backend (healthy) |
## Linear Issue Map
| CUB | Title | Status |
|-----|-------|--------|
| 119 | **Epic: Control Center Refactor — .NET → Go + React** | Todo |
| 120 | PostgreSQL schema + migrations | Done |
| 121 | React pages wired to real API | Done |
| 122 | React frontend scaffold | Done |
| 123 | Gateway integration + SSE streaming | Done |
| 124 | Go backend scaffold | Done |
| 125 | Real-time SSE frontend | Done |
| 126 | Docker Compose deployment | Done |
| 127 | CRUD API endpoints | Done |
| 200 | Live WebSocket gateway client (CUB-200-207 sub-epic) | In Review |
| 201 | agents.list + sessions.list RPC and data mapping | In Review |
| 202 | Real-time event subscription + SSE fan-out | In Review |
| 203 | WS client scaffold — handshake, connect, reconnect loop | In Review |
| 204 | Config, wiring, and graceful fallback | In Review |
| 205 | Unit tests — gateway utility functions | In Review |
| 206 | Unit tests — WSClient handshake, Send/RPC, frame router, reconnect | In Review |
| 207 | Unit tests — event handlers and initial sync | In Review |
### Legacy Issues (Angular/ASP.NET — all Done)
CUB-19 through CUB-63: All 27 Control Center issues completed, including minion mapping, breakroom UI, dark mode theme, agent cards, quick-jump drawer, adaptive nav, SignalR hub, and status animations.
## Known Limitations / Next Steps
1. Agent detail/history views are scaffolded but not fully implemented
2. 16-bit minion breakroom concept (CUB-59-63) was on Angular — needs React port if desired
3. `.env` must be created from `.env.example` with a valid `GATEWAY_URL` for live agent data
4. Docker containers not currently running — start with `docker compose up --build -d`
## Default Agent Assignments
| Area | Agent | Notes |
|------|-------|-------|
| Backend (Go API, Gateway WS, SSE) | Dex | gitea-dex MCP |
| Database (PostgreSQL schema) | Hex | gitea-hex MCP |
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
| Design (wireframes, UX) | Sketch | |
## Getting Started
```bash
cd /mnt/ai-storage/projects/Control-Center
git checkout dev
git pull origin dev
# Docker Compose (recommended)
cp .env.example .env # edit GATEWAY_URL first
docker compose up --build -d
# Manual
cd go-backend && go run cmd/server/main.go # → :8080
cd frontend && npm install && npm run dev # → :5173 (Vite proxy to :8080)
```
-33
View File
@@ -1,33 +0,0 @@
# 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
View File
@@ -1,44 +0,0 @@
# 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
-38
View File
@@ -1,38 +0,0 @@
# ============================================================
# 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
View File
@@ -1,59 +0,0 @@
# 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.
-54
View File
@@ -1,54 +0,0 @@
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
View File
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
{
"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"
}
}
@@ -1,140 +0,0 @@
<!-- ============================================================================
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>
@@ -1,500 +0,0 @@
// ============================================================================
// 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;
}
}
@@ -1,268 +0,0 @@
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);
}
}
@@ -1,2 +0,0 @@
export { AgentSessionDrawerComponent } from './agent-session-drawer.component';
export type { SessionLogLine, SessionMessage } from './agent-session-drawer.component';
-11
View File
@@ -1,11 +0,0 @@
// ============================================================================
// OpenClaw Control Center — Design System Barrel Export
// ============================================================================
// Import everything from '@app/design' for convenient access.
//
// Usage:
// import { CcTokens, CcThemeService, CcCssProps } from '@app/design';
// ============================================================================
export * from './tokens';
export * from './theme.service';
@@ -1,151 +0,0 @@
// ============================================================================
// OpenClaw Control Center — Theme Service
// ============================================================================
// Angular service providing programmatic access to design tokens, theme
// mode switching (dark/light), and runtime CSS custom property manipulation.
//
// Usage:
// constructor(private theme: CcThemeService) {}
//
// // Read a token
// const primary = this.theme.getToken('--cc-color-primary');
//
// // Set a token at runtime
// this.theme.setToken('--cc-color-primary', '#00ff00');
//
// // Toggle theme
// this.theme.setMode('light');
// ============================================================================
import { Injectable, signal, computed, effect } from '@angular/core';
import { CcCssProps, getStatusColor, setCssToken, getCssToken } from './tokens';
// ---------------------------------------------------------------------------
// Theme Mode Types
// ---------------------------------------------------------------------------
export type ThemeMode = 'dark' | 'light';
// ---------------------------------------------------------------------------
// Light theme overrides (future use)
// ---------------------------------------------------------------------------
const LIGHT_THEME_OVERRIDES: Record<string, string> = {
// Surface tokens
'--cc-surface-darkest': '#F8FAFC',
'--cc-surface-dark': '#FFFFFF',
'--cc-surface-medium': '#F1F5F9',
'--cc-surface-light': '#E2E8F0',
'--cc-surface-lighter': '#CBD5E1',
// On-surface tokens
'--cc-on-surface': '#0F172A',
'--cc-on-surface-variant': '#475569',
'--cc-on-surface-muted': '#94A3B8',
// Border
'--cc-surface-lighter-alt': '#E2E8F0',
// M3 system overrides for light
'--mat-sys-surface': '#FFFFFF',
'--mat-sys-surface-container': '#F1F5F9',
'--mat-sys-surface-container-high': '#E2E8F0',
'--mat-sys-on-surface': '#0F172A',
'--mat-sys-on-surface-variant': '#475569',
'--mat-sys-outline': '#CBD5E1',
'--mat-sys-background': '#F8FAFC',
};
// ---------------------------------------------------------------------------
// Dark theme (matches the SCSS defaults)
// ---------------------------------------------------------------------------
const DARK_THEME_OVERRIDES: Record<string, string> = {
'--cc-surface-darkest': '#0D0F12',
'--cc-surface-dark': '#13161A',
'--cc-surface-medium': '#1C2027',
'--cc-surface-light': '#252B33',
'--cc-surface-lighter': '#2D3748',
'--cc-on-surface': '#E2E8F0',
'--cc-on-surface-variant': '#8A9BB0',
'--cc-on-surface-muted': '#64748B',
'--mat-sys-surface': '#13161A',
'--mat-sys-surface-container': '#1C2027',
'--mat-sys-surface-container-high': '#252B33',
'--mat-sys-on-surface': '#E2E8F0',
'--mat-sys-on-surface-variant': '#8A9BB0',
'--mat-sys-outline': '#2D3748',
'--mat-sys-background': '#0D0F12',
};
@Injectable({ providedIn: 'root' })
export class CcThemeService {
// ---------------------------------------------------------------------------
// Signals for reactive theme state
// ---------------------------------------------------------------------------
private readonly _mode = signal<ThemeMode>(
(localStorage.getItem('cc-theme') as ThemeMode) ?? 'dark'
);
/** Current theme mode */
readonly mode = this._mode.asReadonly();
/** Computed: is the current mode dark? */
readonly isDark = computed(() => this._mode() === 'dark');
/** Computed: is the current mode light? */
readonly isLight = computed(() => this._mode() === 'light');
constructor() {
// Apply theme on init and whenever mode changes
effect(() => {
this.applyTheme(this._mode());
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Set the theme mode and persist to localStorage */
setMode(mode: ThemeMode): void {
this._mode.set(mode);
localStorage.setItem('cc-theme', mode);
}
/** Toggle between dark and light mode */
toggle(): void {
this.setMode(this._mode() === 'dark' ? 'light' : 'dark');
}
/** Read a CSS custom property from the document root */
getToken(property: string): string {
return getCssToken(property);
}
/** Set a CSS custom property on the document root */
setToken(property: string, value: string): void {
setCssToken(property, value);
}
/** Get status color set by agent status */
getStatusColors(status: string): { fg: string; bg: string; border: string } {
return getStatusColor(status);
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
/** Apply a theme mode by setting all CSS custom properties */
private applyTheme(mode: ThemeMode): void {
const overrides = mode === 'dark' ? DARK_THEME_OVERRIDES : LIGHT_THEME_OVERRIDES;
// Set color-scheme for native form controls
document.documentElement.style.setProperty('color-scheme', mode);
// Apply all overrides
for (const [prop, value] of Object.entries(overrides)) {
document.documentElement.style.setProperty(prop, value);
}
}
}
-462
View File
@@ -1,462 +0,0 @@
// ============================================================================
// OpenClaw Control Center — Design Tokens (TypeScript)
// ============================================================================
// Typed representation of the design system tokens for programmatic access.
// These mirror the SCSS tokens in styles/_tokens.scss and the CSS custom
// properties emitted by styles/_css-properties.scss.
//
// Usage:
// import { CcTokens } from '@app/design/tokens';
// const primary = CcTokens.color.primary;
// const surface = CcTokens.surface.dark;
// ============================================================================
// ---------------------------------------------------------------------------
// Color Palette
// ---------------------------------------------------------------------------
export const CcColors = {
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#38bdf8',
600: '#0ea5e9',
700: '#0284c7',
800: '#0369a1',
900: '#075985',
},
secondary: {
50: '#f0fdfa',
100: '#ccfbf1',
200: '#99f6e4',
300: '#5eead4',
400: '#2dd4bf',
500: '#14b8a6',
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
},
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
} as const;
// ---------------------------------------------------------------------------
// Semantic Colors (Tactical Dark)
// ---------------------------------------------------------------------------
export const CcSemanticColors = {
surface: {
darkest: '#0D0F12',
dark: '#13161A',
medium: '#1C2027',
light: '#252B33',
lighter: '#2D3748',
},
onSurface: {
primary: '#E2E8F0',
variant: '#8A9BB0',
muted: '#64748B',
},
} as const;
// ---------------------------------------------------------------------------
// Status Colors
// ---------------------------------------------------------------------------
export const CcStatusColors = {
active: { fg: '#38bdf8', bg: 'rgba(56, 189, 248, 0.12)', border: 'rgba(56, 189, 248, 0.40)' },
idle: { fg: '#2dd4bf', bg: 'rgba(45, 212, 191, 0.12)', border: 'rgba(45, 212, 191, 0.40)' },
thinking: { fg: '#a78bfa', bg: 'rgba(167, 139, 250, 0.12)', border: 'rgba(167, 139, 250, 0.40)' },
error: { fg: '#f87171', bg: 'rgba(248, 113, 113, 0.12)', border: 'rgba(248, 113, 113, 0.40)' },
offline: { fg: '#64748b', bg: 'rgba(100, 116, 139, 0.12)', border: 'rgba(100, 116, 139, 0.40)' },
} as const;
// ---------------------------------------------------------------------------
// 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;
}
@@ -1 +0,0 @@
export { LongPressDirective } from './long-press.directive';
@@ -1,89 +0,0 @@
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;
}
}
}
@@ -1,85 +0,0 @@
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();
}
}
@@ -1,153 +0,0 @@
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
}
}
@@ -1,115 +0,0 @@
// ============================================================================
// OpenClaw Control Center — CSS Custom Property Output
// ============================================================================
// This module emits ALL design tokens as CSS custom properties on :root.
// Import this in styles.scss to make tokens available to all components.
//
// Tokens are namespaced with --cc- (Control Center) to avoid collisions
// with Angular Material's --mat- variables.
// ============================================================================
@use 'tokens' as *;
// ---------------------------------------------------------------------------
// Emit all CSS custom properties
// ---------------------------------------------------------------------------
@mixin emit-custom-properties {
:root {
// --- Color tokens ---
--cc-color-primary: #{$color-primary-500};
--cc-color-secondary: #{$color-secondary-400};
--cc-color-accent: #{$color-accent-400};
--cc-color-danger: #{$color-danger-400};
// --- Surface tokens ---
--cc-surface-darkest: #{$color-surface-darkest};
--cc-surface-dark: #{$color-surface-dark};
--cc-surface-medium: #{$color-surface-medium};
--cc-surface-light: #{$color-surface-light};
--cc-surface-lighter: #{$color-surface-lighter};
// --- On-surface tokens ---
--cc-on-surface: #{$color-on-surface};
--cc-on-surface-variant: #{$color-on-surface-variant};
--cc-on-surface-muted: #{$color-on-surface-muted};
// --- Status tokens ---
--cc-status-active: #{$status-active};
--cc-status-idle: #{$status-idle};
--cc-status-thinking: #{$status-thinking};
--cc-status-error: #{$status-error};
--cc-status-offline: #{$status-offline};
--cc-status-active-bg: #{$status-active-bg};
--cc-status-idle-bg: #{$status-idle-bg};
--cc-status-thinking-bg: #{$status-thinking-bg};
--cc-status-error-bg: #{$status-error-bg};
--cc-status-offline-bg: #{$status-offline-bg};
--cc-status-active-border: #{$status-active-border};
--cc-status-idle-border: #{$status-idle-border};
--cc-status-thinking-border: #{$status-thinking-border};
--cc-status-error-border: #{$status-error-border};
--cc-status-offline-border: #{$status-offline-border};
// --- Typography tokens ---
--cc-font-brand: #{$font-family-brand};
--cc-font-body: #{$font-family-body};
--cc-font-mono: #{$font-family-mono};
// --- Spacing tokens ---
--cc-spacing-0: #{$spacing-0};
--cc-spacing-1: #{$spacing-1};
--cc-spacing-2: #{$spacing-2};
--cc-spacing-3: #{$spacing-3};
--cc-spacing-4: #{$spacing-4};
--cc-spacing-5: #{$spacing-5};
--cc-spacing-6: #{$spacing-6};
--cc-spacing-7: #{$spacing-7};
--cc-spacing-8: #{$spacing-8};
--cc-spacing-10: #{$spacing-10};
--cc-spacing-12: #{$spacing-12};
--cc-spacing-16: #{$spacing-16};
// --- Layout tokens ---
--cc-nav-rail-collapsed: #{$nav-rail-collapsed-width};
--cc-nav-rail-expanded: #{$nav-rail-expanded-width};
--cc-header-height: #{$header-height};
--cc-bottom-nav-height: #{$bottom-nav-height};
--cc-card-radius: #{$card-border-radius};
--cc-card-min-width: #{$card-min-width};
// --- Radius tokens ---
--cc-radius-none: #{$radius-none};
--cc-radius-xs: #{$radius-xs};
--cc-radius-sm: #{$radius-sm};
--cc-radius-md: #{$radius-md};
--cc-radius-lg: #{$radius-lg};
--cc-radius-xl: #{$radius-xl};
--cc-radius-full: #{$radius-full};
// --- Shadow tokens ---
--cc-shadow-0: #{$shadow-level-0};
--cc-shadow-1: #{$shadow-level-1};
--cc-shadow-2: #{$shadow-level-2};
--cc-shadow-3: #{$shadow-level-3};
--cc-shadow-4: #{$shadow-level-4};
// --- Motion tokens ---
--cc-duration-fast: #{$duration-fast};
--cc-duration-short: #{$duration-short};
--cc-duration-medium: #{$duration-medium};
--cc-duration-standard: #{$duration-standard};
--cc-duration-long: #{$duration-long};
--cc-easing-standard: #{$easing-standard};
--cc-easing-decelerate: #{$easing-decelerate};
--cc-easing-accelerate: #{$easing-accelerate};
// --- Accessibility tokens ---
--cc-focus-width: #{$focus-ring-width};
--cc-focus-offset: #{$focus-ring-offset};
--cc-focus-color: #{$focus-ring-color};
--cc-touch-min: #{$min-touch-target};
}
}
-474
View File
@@ -1,474 +0,0 @@
// ============================================================================
// OpenClaw Control Center — M3 Design Tokens
// ============================================================================
// Single source of truth for all design tokens.
// Components should @use this module and reference tokens via variables or
// the theme() mixin rather than hardcoding values.
//
// Token structure:
// 1. Color tokens — palette, semantic, status, surface
// 2. Typography — families, sizes, weights, line-heights
// 3. Spacing — 4px base grid, named steps
// 4. Layout — dimensions, breakpoints, radii, shadows
// 5. Motion — durations, easing curves
// 6. Accessibility — focus, reduced-motion
// ============================================================================
@use 'sass:map';
@use 'sass:meta';
// ============================================================================
// 1. COLOR TOKENS
// ============================================================================
// ---------------------------------------------------------------------------
// 1a. Primary Palette (M3 cyan-based)
// ---------------------------------------------------------------------------
$color-primary-50: #ecfeff;
$color-primary-100: #cffafe;
$color-primary-200: #a5f3fc;
$color-primary-300: #67e8f9;
$color-primary-400: #22d3ee;
$color-primary-500: #38bdf8; // Brand primary
$color-primary-600: #0ea5e9;
$color-primary-700: #0284c7;
$color-primary-800: #0369a1;
$color-primary-900: #075985;
// ---------------------------------------------------------------------------
// 1b. Secondary Palette (M3 teal-based)
// ---------------------------------------------------------------------------
$color-secondary-50: #f0fdfa;
$color-secondary-100: #ccfbf1;
$color-secondary-200: #99f6e4;
$color-secondary-300: #5eead4;
$color-secondary-400: #2dd4bf; // Brand secondary
$color-secondary-500: #14b8a6;
$color-secondary-600: #0d9488;
$color-secondary-700: #0f766e;
$color-secondary-800: #115e59;
$color-secondary-900: #134e4a;
// ---------------------------------------------------------------------------
// 1c. Accent / Tertiary Palette (M3 violet-based)
// ---------------------------------------------------------------------------
$color-accent-50: #f5f3ff;
$color-accent-100: #ede9fe;
$color-accent-200: #ddd6fe;
$color-accent-300: #c4b5fd;
$color-accent-400: #a78bfa; // Brand accent
$color-accent-500: #8b5cf6;
$color-accent-600: #7c3aed;
$color-accent-700: #6d28d9;
$color-accent-800: #5b21b6;
$color-accent-900: #4c1d95;
// ---------------------------------------------------------------------------
// 1d. Danger / Error Palette
// ---------------------------------------------------------------------------
$color-danger-50: #fef2f2;
$color-danger-100: #fee2e2;
$color-danger-200: #fecaca;
$color-danger-300: #fca5a5;
$color-danger-400: #f87171; // Brand danger
$color-danger-500: #ef4444;
$color-danger-600: #dc2626;
$color-danger-700: #b91c1c;
$color-danger-800: #991b1b;
$color-danger-900: #7f1d1d;
// ---------------------------------------------------------------------------
// 1e. Semantic Surface Tokens (Tactical Dark)
// ---------------------------------------------------------------------------
$color-surface-darkest: #0D0F12; // Page background
$color-surface-dark: #13161A; // Card / container surface
$color-surface-medium: #1C2027; // Container-elevated
$color-surface-light: #252B33; // Container-high / hover
$color-surface-lighter: #2D3748; // Border / divider zone
$color-on-surface: #E2E8F0; // Primary text on dark surfaces
$color-on-surface-variant: #8A9BB0; // Secondary / muted text
$color-on-surface-muted: #64748B; // Disabled / hint text
// ---------------------------------------------------------------------------
// 1f. Status Colors (Semantic — outside M3 tonal system)
// ---------------------------------------------------------------------------
$status-active: #38bdf8;
$status-idle: #2dd4bf;
$status-thinking: #a78bfa;
$status-error: #f87171;
$status-offline: #64748b;
// Status background tints (12% opacity for badges, pills, backgrounds)
$status-active-bg: rgba(56, 189, 248, 0.12);
$status-idle-bg: rgba(45, 212, 191, 0.12);
$status-thinking-bg: rgba(167, 139, 250, 0.12);
$status-error-bg: rgba(248, 113, 113, 0.12);
$status-offline-bg: rgba(100, 116, 139, 0.12);
// Status border colors (40% opacity)
$status-active-border: rgba(56, 189, 248, 0.40);
$status-idle-border: rgba(45, 212, 191, 0.40);
$status-thinking-border: rgba(167, 139, 250, 0.40);
$status-error-border: rgba(248, 113, 113, 0.40);
$status-offline-border: rgba(100, 116, 139, 0.40);
// Map for iteration
$status-colors: (
'active': ('fg': $status-active, 'bg': $status-active-bg, 'border': $status-active-border),
'idle': ('fg': $status-idle, 'bg': $status-idle-bg, 'border': $status-idle-border),
'thinking': ('fg': $status-thinking, 'bg': $status-thinking-bg, 'border': $status-thinking-border),
'error': ('fg': $status-error, 'bg': $status-error-bg, 'border': $status-error-border),
'offline': ('fg': $status-offline, 'bg': $status-offline-bg, 'border': $status-offline-border),
);
// ---------------------------------------------------------------------------
// 1g. Full color map for programmatic access
// ---------------------------------------------------------------------------
$colors: (
'primary': $color-primary-500,
'secondary': $color-secondary-400,
'accent': $color-accent-400,
'danger': $color-danger-400,
'surface': $color-surface-dark,
'surface-light': $color-surface-light,
'on-surface': $color-on-surface,
'on-surface-variant': $color-on-surface-variant,
'border': $color-surface-lighter,
);
// ============================================================================
// 2. TYPOGRAPHY TOKENS
// ============================================================================
// ---------------------------------------------------------------------------
// 2a. Font Families
// ---------------------------------------------------------------------------
$font-family-brand: 'Inter, Roboto, sans-serif';
$font-family-body: 'Inter, Roboto, sans-serif';
$font-family-mono: 'Roboto Mono, Cascadia Code, Fira Code, monospace';
// ---------------------------------------------------------------------------
// 2b. Font Sizes (M3 type scale)
// ---------------------------------------------------------------------------
$font-size-display-large: 57px;
$font-size-display-medium: 45px;
$font-size-display-small: 36px;
$font-size-headline-large: 32px;
$font-size-headline-medium: 28px;
$font-size-headline-small: 24px;
$font-size-title-large: 22px;
$font-size-title-medium: 16px;
$font-size-title-small: 14px;
$font-size-body-large: 16px;
$font-size-body-medium: 14px;
$font-size-body-small: 12px;
$font-size-label-large: 14px;
$font-size-label-medium: 12px;
$font-size-label-small: 11px;
// ---------------------------------------------------------------------------
// 2c. Font Weights
// ---------------------------------------------------------------------------
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-heavy: 700;
// ---------------------------------------------------------------------------
// 2d. Line Heights
// ---------------------------------------------------------------------------
$line-height-tight: 1.2;
$line-height-normal: 1.5;
$line-height-relaxed: 1.6;
// ---------------------------------------------------------------------------
// 2e. Letter Spacing
// ---------------------------------------------------------------------------
$letter-spacing-tight: -0.01em;
$letter-spacing-normal: 0em;
$letter-spacing-wide: 0.02em;
$letter-spacing-mono: 0.05em;
// ---------------------------------------------------------------------------
// 2f. Typography map
// ---------------------------------------------------------------------------
$typography: (
'font-family-brand': $font-family-brand,
'font-family-body': $font-family-body,
'font-family-mono': $font-family-mono,
'size-display-large': $font-size-display-large,
'size-headline-medium': $font-size-headline-medium,
'size-title-large': $font-size-title-large,
'size-title-medium': $font-size-title-medium,
'size-body-large': $font-size-body-large,
'size-body-medium': $font-size-body-medium,
'size-body-small': $font-size-body-small,
'size-label-large': $font-size-label-large,
'size-label-medium': $font-size-label-medium,
'weight-regular': $font-weight-regular,
'weight-medium': $font-weight-medium,
'weight-bold': $font-weight-bold,
);
// ============================================================================
// 3. SPACING TOKENS (4px grid)
// ============================================================================
$spacing-0: 0px;
$spacing-1: 4px;
$spacing-2: 8px;
$spacing-3: 12px;
$spacing-4: 16px;
$spacing-5: 20px;
$spacing-6: 24px;
$spacing-7: 28px;
$spacing-8: 32px;
$spacing-9: 36px;
$spacing-10: 40px;
$spacing-12: 48px;
$spacing-14: 56px;
$spacing-16: 64px;
$spacing-20: 80px;
// Named semantic spacing
$spacing-unit: $spacing-2; // 8px — base grid unit
$spacing-card-gap: $spacing-4; // 16px
$spacing-card-pad: $spacing-5; // 20px
$spacing-section: $spacing-6; // 24px
// ============================================================================
// 4. LAYOUT TOKENS
// ============================================================================
// ---------------------------------------------------------------------------
// 4a. Dimensions
// ---------------------------------------------------------------------------
$nav-rail-collapsed-width: 72px;
$nav-rail-expanded-width: 256px;
$header-height: 64px;
$bottom-nav-height: 80px;
$card-border-radius: 16px;
$card-min-width: 320px;
$badge-height: 24px;
$badge-border-radius: 12px;
$status-dot-size: 10px;
// ---------------------------------------------------------------------------
// 4b. Breakpoints (M3 canonical)
// ---------------------------------------------------------------------------
$breakpoint-compact: 599px; // Mobile phone
$breakpoint-medium: 767px; // Tablet portrait
$breakpoint-expanded: 1023px; // Tablet landscape
$breakpoint-large: 1439px; // Desktop
// Named breakpoint map for @media mixins
$breakpoints: (
'compact': $breakpoint-compact,
'medium': $breakpoint-medium,
'expanded': $breakpoint-expanded,
'large': $breakpoint-large,
);
// ---------------------------------------------------------------------------
// 4c. Border Radius
// ---------------------------------------------------------------------------
$radius-none: 0px;
$radius-xs: 4px;
$radius-sm: 8px;
$radius-md: 12px;
$radius-lg: 16px;
$radius-xl: 24px;
$radius-full: 9999px;
// ---------------------------------------------------------------------------
// 4d. Shadows (M3 elevation)
// ---------------------------------------------------------------------------
$shadow-level-0: none;
$shadow-level-1: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
$shadow-level-2: 0 2px 6px 0 rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
$shadow-level-3: 0 4px 12px 0 rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
$shadow-level-4: 0 8px 24px 0 rgba(0, 0, 0, 0.3), 0 8px 16px -8px rgba(0, 0, 0, 0.3);
// ============================================================================
// 5. MOTION TOKENS
// ============================================================================
$duration-instant: 0ms; // No animation (reduced motion fallback)
$duration-fast: 100ms;
$duration-short: 150ms;
$duration-medium: 200ms;
$duration-standard: 300ms;
$duration-long: 500ms;
$easing-standard: cubic-bezier(0.4, 0, 0.2, 1); // M3 standard
$easing-decelerate: cubic-bezier(0, 0, 0.2, 1); // M3 decelerate (entering)
$easing-accelerate: cubic-bezier(0.4, 0, 1, 1); // M3 accelerate (exiting)
$easing-sharp: cubic-bezier(0.4, 0, 0.6, 1); // M3 sharp
// ============================================================================
// 6. ACCESSIBILITY TOKENS
// ============================================================================
$focus-ring-width: 2px;
$focus-ring-offset: 2px;
$focus-ring-color: $status-active;
$focus-ring-style: solid;
$min-touch-target: 48px;
$min-body-font: 16px;
// ============================================================================
// MIXINS
// ============================================================================
// ---------------------------------------------------------------------------
// Responsive breakpoint mixin
// Usage: @include tokens.respond-to('expanded') { ... }
// ---------------------------------------------------------------------------
@mixin respond-to($breakpoint) {
$value: map.get($breakpoints, $breakpoint);
@if $value {
@media (min-width: $value + 1) {
@content;
}
} @else {
@error "Unknown breakpoint: `#{$breakpoint}`. Valid: compact, medium, expanded, large";
}
}
// ---------------------------------------------------------------------------
// Below-breakpoint mixin (max-width)
// Usage: @include tokens.below('expanded') { ... }
// ---------------------------------------------------------------------------
@mixin below($breakpoint) {
$value: map.get($breakpoints, $breakpoint);
@if $value {
@media (max-width: $value) {
@content;
}
} @else {
@error "Unknown breakpoint: `#{$breakpoint}`. Valid: compact, medium, expanded, large";
}
}
// ---------------------------------------------------------------------------
// Focus ring mixin
// ---------------------------------------------------------------------------
@mixin focus-ring($color: $focus-ring-color) {
&:focus-visible {
outline: $focus-ring-width $focus-ring-style $color;
outline-offset: $focus-ring-offset;
}
}
// ---------------------------------------------------------------------------
// Card surface mixin
// ---------------------------------------------------------------------------
@mixin card-surface {
background-color: $color-surface-medium;
border-radius: $card-border-radius;
border: 1px solid $color-surface-lighter;
}
// ---------------------------------------------------------------------------
// Mono text mixin
// ---------------------------------------------------------------------------
@mixin mono-text($size: $font-size-body-medium) {
font-family: $font-family-mono;
font-size: $size;
letter-spacing: $letter-spacing-mono;
}
// ---------------------------------------------------------------------------
// Status dot mixin
// ---------------------------------------------------------------------------
@mixin status-dot($status) {
$colors: map.get($status-colors, $status);
@if not $colors {
@error "Unknown status: `#{$status}`. Valid: active, idle, thinking, error, offline";
}
$fg: map.get($colors, 'fg');
width: $status-dot-size;
height: $status-dot-size;
border-radius: $radius-full;
background-color: $fg;
@if $status == 'active' {
animation: pulse-active $duration-standard $easing-standard infinite;
} @else if $status == 'thinking' {
animation: pulse-thinking 3s $easing-standard infinite;
} @else if $status == 'error' {
animation: pulse-error $duration-fast $easing-standard infinite;
}
}
// ---------------------------------------------------------------------------
// Truncate text mixin (single line)
// ---------------------------------------------------------------------------
@mixin truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// ---------------------------------------------------------------------------
// Screen-reader-only mixin
// ---------------------------------------------------------------------------
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// ---------------------------------------------------------------------------
// Touch target mixin — ensures minimum 48px touch area
// ---------------------------------------------------------------------------
@mixin touch-target($min-size: $min-touch-target) {
min-width: $min-size;
min-height: $min-size;
}
// ============================================================================
// CUB-20 / CUB-27 CONVENIENCE ALIASES
// ============================================================================
// Short aliases used by agent-card, hub-page, and other components.
// These map to the canonical M3 tokens above.
$cc-background: $color-surface-darkest;
$cc-surface: $color-surface-dark;
$cc-surface-container: $color-surface-medium;
$cc-surface-container-high: $color-surface-light;
$cc-on-surface: $color-on-surface;
$cc-on-surface-variant: $color-on-surface-variant;
$cc-outline: $color-surface-lighter;
$cc-font-mono: $font-family-mono;
$cc-font-family: $font-family-brand;
$cc-nav-rail-collapsed-width: $nav-rail-collapsed-width;
$cc-nav-rail-expanded-width: $nav-rail-expanded-width;
$cc-header-height: $header-height;
$cc-bottom-nav-height: $bottom-nav-height;
$cc-card-border-radius: $card-border-radius;
$cc-card-min-width: $card-min-width;
$cc-card-gap: $spacing-card-gap;
$cc-card-padding: $spacing-card-pad;
$cc-section-padding: $spacing-section;
$cc-spacing-unit: $spacing-unit;
$cc-breakpoint-mobile: $breakpoint-compact;
$cc-breakpoint-tablet: $breakpoint-medium;
$cc-breakpoint-desktop: $breakpoint-expanded;
$cc-surface-darkest: $color-surface-darkest;
$cc-surface-dark: $color-surface-dark;
$cc-surface-medium: $color-surface-medium;
$cc-surface-light: $color-surface-light;
$cc-surface-lighter: $color-surface-lighter;
-170
View File
@@ -1,170 +0,0 @@
// ============================================================================
// OpenClaw Control Center — Utility Mixins
// ============================================================================
// Reusable patterns that enforce design-system consistency.
// Components should @use this module and include mixins rather than
// writing repetitive CSS blocks.
// ============================================================================
@use 'tokens' as *;
// ---------------------------------------------------------------------------
// Elevation / Surface Card
// Applies consistent card styling using design tokens.
// Usage: @include utils.card-surface();
// ---------------------------------------------------------------------------
@mixin card-surface($elevation: 1) {
background-color: var(--cc-surface-medium);
border-radius: var(--cc-card-radius);
border: 1px solid var(--cc-surface-lighter);
box-shadow: var(--cc-shadow-#{$elevation});
transition: box-shadow var(--cc-duration-short) var(--cc-easing-standard);
}
// ---------------------------------------------------------------------------
// Elevated card on hover
// ---------------------------------------------------------------------------
@mixin card-hover($elevation: 2) {
&:hover {
box-shadow: var(--cc-shadow-#{$elevation});
}
}
// ---------------------------------------------------------------------------
// Status-aware left border
// Applies colored left border based on agent status.
// Usage: @include utils.status-border('active');
// ---------------------------------------------------------------------------
@mixin status-border($status) {
$status-map: (
'active': var(--cc-status-active),
'idle': var(--cc-status-idle),
'thinking': var(--cc-status-thinking),
'error': var(--cc-status-error),
'offline': var(--cc-status-offline),
);
$color: map-get($status-map, $status);
@if not $color {
$color: var(--cc-status-offline);
}
border-left: 4px solid $color;
}
// ---------------------------------------------------------------------------
// Status badge / pill
// ---------------------------------------------------------------------------
@mixin status-badge($status) {
$fg-map: (
'active': var(--cc-status-active),
'idle': var(--cc-status-idle),
'thinking': var(--cc-status-thinking),
'error': var(--cc-status-error),
'offline': var(--cc-status-offline),
);
$bg-map: (
'active': var(--cc-status-active-bg),
'idle': var(--cc-status-idle-bg),
'thinking': var(--cc-status-thinking-bg),
'error': var(--cc-status-error-bg),
'offline': var(--cc-status-offline-bg),
);
display: inline-flex;
align-items: center;
gap: 6px;
height: $badge-height;
padding: 0 8px;
border-radius: $badge-border-radius;
background-color: map-get($bg-map, $status);
color: map-get($fg-map, $status);
font-size: $font-size-label-medium;
font-weight: $font-weight-medium;
white-space: nowrap;
}
// ---------------------------------------------------------------------------
// Glass surface (frosted glass effect)
// ---------------------------------------------------------------------------
@mixin glass-surface {
background-color: rgba(19, 22, 26, 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--cc-surface-lighter);
}
// ---------------------------------------------------------------------------
// Responsive grid
// Creates a responsive grid that adapts from 1-col to 2-col.
// ---------------------------------------------------------------------------
@mixin responsive-grid($min-col-width: $card-min-width, $gap: $spacing-card-gap) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax($min-col-width, 1fr));
gap: $gap;
}
// ---------------------------------------------------------------------------
// Scroll container
// ---------------------------------------------------------------------------
@mixin scroll-container($direction: 'y') {
@if $direction == 'y' {
overflow-y: auto;
overflow-x: hidden;
} @else {
overflow-x: auto;
overflow-y: hidden;
}
-webkit-overflow-scrolling: touch;
// Custom scrollbar (tactical dark)
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: var(--cc-surface-dark);
}
&::-webkit-scrollbar-thumb {
background: var(--cc-surface-lighter);
border-radius: 3px;
&:hover {
background: var(--cc-on-surface-variant);
}
}
}
// ---------------------------------------------------------------------------
// Page container
// Standard page padding and layout
// ---------------------------------------------------------------------------
@mixin page-container {
padding: $spacing-section;
min-height: 400px;
overflow-x: hidden;
}
// ---------------------------------------------------------------------------
// Transition helpers
// ---------------------------------------------------------------------------
@mixin transition-colors($duration: $duration-short) {
transition: color #{$duration} $easing-standard,
background-color #{$duration} $easing-standard,
border-color #{$duration} $easing-standard;
}
@mixin transition-transform($duration: $duration-medium) {
transition: transform #{$duration} $easing-standard;
}
@mixin transition-opacity($duration: $duration-short) {
transition: opacity #{$duration} $easing-standard;
}
// ---------------------------------------------------------------------------
// Reduced motion
// Wraps content in a reduced-motion media query.
// ---------------------------------------------------------------------------
@mixin reduced-motion {
@media (prefers-reduced-motion: reduce) {
@content;
}
}
-15
View File
@@ -1,15 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
-30
View File
@@ -1,30 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}
+39 -19
View File
@@ -1,24 +1,44 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
node_modules
dist
dist-ssr
*.local
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Editor directories and files
# 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
.idea
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Thumbs.db
-38
View File
@@ -1,38 +0,0 @@
# ============================================================
# Control Center Frontend — Multi-stage Docker Build
# React 19 + Vite + nginx for static serving + API proxy
# ============================================================
# --- Build Stage ---
FROM node:22-slim AS builder
WORKDIR /app
# Install dependencies first (layer caching)
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build production bundle
COPY . .
RUN npm run build
# --- Runtime Stage ---
FROM nginx:1.27-alpine AS runtime
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built React app from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose HTTP port
EXPOSE 80
# Health check — confirm nginx is serving
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+50 -64
View File
@@ -1,73 +1,59 @@
# React + TypeScript + Vite
# Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
Currently, two official plugins are available:
## Development server
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
To start a local development server, run:
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
ng serve
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
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.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
## Code scaffolding
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
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.
@@ -53,13 +53,7 @@
],
"styles": [
"src/styles.scss"
],
"stylePreprocessorOptions": {
"includePaths": [
"src",
"src/styles"
]
}
]
},
"configurations": {
"production": {
-22
View File
@@ -1,22 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-41
View File
@@ -1,41 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Cache static assets (Vite uses content hashes)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache hashed JS/CSS bundles
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# React SPA — all other routes fall back to index.html
location / {
try_files $uri $uri/ /index.html;
}
}
+6330 -2701
View File
File diff suppressed because it is too large Load Diff
+24 -34
View File
@@ -1,44 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"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": {
"@tanstack/react-query": "^5.100.9",
"axios": "^1.16.0",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0"
"@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": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.14",
"tailwindcss": "^4.2.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10",
"vitest": "^4.1.7"
"@angular/build": "^21.2.8",
"@angular/cli": "^21.2.8",
"@angular/compiler-cli": "^21.2.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
}
}

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

-23
View File
@@ -1,23 +0,0 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import HubPage from './pages/HubPage'
import LogsPage from './pages/LogsPage'
import ProjectsPage from './pages/ProjectsPage'
import SessionsPage from './pages/SessionsPage'
import SettingsPage from './pages/SettingsPage'
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<HubPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Layout>
)
}
export default App
@@ -2,19 +2,12 @@
<!-- 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()"
[attr.aria-label]="displayName + ' — ' + statusLabel()"
[style.border-left-color]="statusBorderColor()"
(click)="cardClick.emit(sessionKey)"
appLongPress
[appLongPressDuration]="500"
(appLongPress)="cardLongPress.emit(sessionKey)"
tabindex="0"
>
<!-- ── Header: status badge + agent info ── -->
@@ -34,20 +27,19 @@
</div>
<!-- ── Body: current task ── -->
<div class="agent-card__body" *ngIf="task || isError()">
<div class="agent-card__body" *ngIf="task || status === 'error'">
<p
class="agent-card__task"
[class.agent-card__task--error]="isError()"
[attr.aria-label]="'Current task: ' + (isError() ? errorMessage || task : task)"
[attr.aria-label]="'Current task: ' + (status === 'error' ? errorMessage : task)"
>
{{ isError() ? errorMessage || task : task }}
{{ status === 'error' ? errorMessage || task : task }}
</p>
</div>
<!-- ── Task Progress Bar ── -->
<div
class="agent-card__progress"
*ngIf="showProgress()"
*ngIf="progress > 0 && status !== 'error'"
[attr.aria-label]="'Task progress: ' + progress + '%'"
>
<mat-progress-bar
@@ -58,16 +50,6 @@
<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">
@@ -2,18 +2,15 @@
// 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;
min-width: var(--cc-card-min-width);
padding: var(--cc-card-padding);
background-color: var(--cc-surface-container);
border-radius: tokens.$cc-card-border-radius;
border-radius: var(--cc-card-border-radius);
border-left: 4px solid var(--status-offline); // default; overridden by [style]
border-top: 1px solid var(--cc-outline);
border-right: 1px solid var(--cc-outline);
@@ -26,14 +23,6 @@
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;
@@ -59,6 +48,11 @@
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--cc-on-surface);
// Perstatus background tints
.status-dot--active + & {
background-color: var(--status-active-bg);
}
}
.agent-card__status-label {
@@ -100,7 +94,8 @@
color: var(--cc-on-surface);
line-height: 1.4;
&--error {
// Error messages get distinct styling
.agent-card--error & {
color: var(--status-error);
}
}
@@ -114,7 +109,6 @@
}
.agent-card__progress-label {
font-family: var(--cc-font-mono);
font-size: 12px;
font-weight: 500;
color: var(--cc-on-surface-variant);
@@ -136,23 +130,6 @@
}
}
// 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;
@@ -176,6 +153,7 @@
color: var(--cc-on-surface-variant);
}
.agent-card__channel-icon,
.agent-card__channel .mat-icon {
font-size: 14px;
width: 14px;
@@ -191,6 +169,7 @@
.agent-card__jump {
flex-shrink: 0;
// Match M3 text button sizing
.mat-mdc-button {
min-width: 36px;
padding: 0 8px;
@@ -205,9 +184,11 @@
}
// Statusspecific background tints for badge
// Using data-status attribute selectors for clean styling.
// We use the global status-dot classes from styles.scss and pair them
// with contextual background-color overrides here.
.agent-card[data-status="active"] .agent-card__badge {
.agent-card[data-status="active"] .agent-card__badge,
.agent-card .status-dot--active ~ .agent-card__badge {
background-color: var(--status-active-bg);
}
@@ -223,22 +204,8 @@
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) {
@media (max-width: 599px) {
.agent-card {
min-width: unset;
padding: 16px;
@@ -1,14 +1,8 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
Output,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
@@ -17,19 +11,11 @@ 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({
@@ -42,13 +28,12 @@ import {
MatButtonModule,
MatProgressBarModule,
MatTooltipModule,
LongPressDirective,
],
templateUrl: './agent-card.component.html',
styleUrl: './agent-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentCardComponent implements OnDestroy {
export class AgentCardComponent {
// --- Six required inputs per spec ---
/** Agent status — drives badge color and leftborder accent */
@@ -83,26 +68,7 @@ export class AgentCardComponent implements OnDestroy {
/** 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 ---
// --- Computed values ---
/** Map status → CSS custom property for the leftborder accent */
readonly statusBorderColor = computed(() => {
@@ -116,68 +82,46 @@ export class AgentCardComponent implements OnDestroy {
return map[this.status] ?? 'var(--status-offline)';
});
/** Humanreadable status label (from design tokens) */
readonly statusLabel = computed(() => STATUS_LABELS[this.status] ?? this.status);
/** Humanreadable status label */
readonly statusLabel = computed(() => {
const labels: Record<AgentStatus, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking…',
error: 'Error',
offline: 'Offline',
};
return labels[this.status] ?? this.status;
});
/** CSS class suffix for the status badge dot */
readonly statusDotClass = computed(() => `status-dot--${this.status}`);
/** Material icon name for the channel (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);
/** Material icon name for the channel */
readonly channelIcon = computed(() => {
const icons: Record<string, string> = {
telegram: 'telegram', // falls back to font icon if no SVG registered
slack: 'chat',
discord: 'forum',
whatsapp: 'chat',
webchat: 'language',
email: 'email',
};
return icons[this.channel] ?? 'chat';
});
/** 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 '';
/** Relative time string for lastActivity */
readonly lastActivityLabel = computed(() => {
if (!this.lastActivity) return '';
const now = Date.now();
const then = date.getTime();
const then = this.lastActivity.getTime();
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}
});
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;
}
}
/** Quickjump route derived from sessionKey */
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
}
@@ -1,5 +1,3 @@
export * from './quick-jump-button/quick-jump-button.component';
export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component';
export { QuickJumpDrawerComponent } from './quick-jump-drawer/index';
export { AgentSessionDrawerComponent } from './agent-session-drawer/index';
export type { SessionLogLine, SessionMessage } from './agent-session-drawer/index';
export { QuickJumpDrawerComponent } from './quick-jump-drawer/index';
@@ -17,11 +17,4 @@
</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)"
/>
<app-quick-jump-drawer />
@@ -0,0 +1,38 @@
import { ChangeDetectionStrategy, Component, HostListener, ViewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NavRailComponent } from '../nav-rail/nav-rail.component';
import { BottomNavComponent } from '../bottom-nav/bottom-nav.component';
import { HeaderBarComponent } from '../header-bar/header-bar.component';
import { QuickJumpDrawerComponent } from '../../components/quick-jump-drawer/index';
/**
* Layout Shell — wraps the main content area with adaptive navigation.
* Desktop/Kiosk: Nav Rail (left) + Header + Content
* Mobile: Header + Content + Bottom Nav
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
*/
@Component({
selector: 'app-layout-shell',
standalone: true,
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent],
templateUrl: './layout-shell.component.html',
styleUrl: './layout-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LayoutShellComponent {
@ViewChild(QuickJumpDrawerComponent) quickJumpDrawer!: QuickJumpDrawerComponent;
/** Open the quick-jump drawer from anywhere in the layout. */
openQuickJump(): void {
this.quickJumpDrawer?.open();
}
/** Global keyboard shortcut: Ctrl+K or Cmd+K opens the quick-jump drawer. */
@HostListener('document:keydown', ['$event'])
onGlobalKeydown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
this.quickJumpDrawer?.toggle();
}
}
}

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