initial commit

This commit is contained in:
cubecraft-agents[bot]
2026-04-25 19:02:57 +00:00
commit f490098af6
60 changed files with 11934 additions and 0 deletions

0
README.md Normal file
View File

32
api-client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@control-center/openclaw-api-client",
"version": "0.1.0",
"description": "OpenClaw Gateway API client and WebSocket integration for the Control Center Command Hub",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "jest",
"lint": "eslint src/"
},
"dependencies": {
"ws": "^8.16.0",
"rxjs": "^7.8.1",
"eventsource": "^2.0.2"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/ws": "^8.5.10",
"@types/node": "^20.11.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
"@types/jest": "^29.5.0",
"eslint": "^8.56.0"
},
"peerDependencies": {
"rxjs": "^7.8.1"
},
"license": "UNLICENSED",
"private": true
}

View File

@@ -0,0 +1,5 @@
/**
* @fileoverview Re-export barrel for all model types.
*/
export * from './types';

View File

@@ -0,0 +1,426 @@
/**
* @fileoverview Core data models for the OpenClaw Control Center API client.
*
* These models bridge OpenClaw Gateway primitives (session keys, status events,
* tool responses) to the AgentCardData interface consumed by the Angular frontend.
*
* Design principle: every model maps 1:1 to a Gateway concept. Transformation
* to UI-specific shapes (like AgentCardData) happens in the service layer.
*/
// ─── Agent Status ──────────────────────────────────────────────────────────
/**
* Agent operational status, derived from session activity and Gateway events.
*
* Mapping from OpenClaw session `status` field:
* - "running" → ACTIVE — agent is currently processing a turn
* - "done" → IDLE — agent completed its last turn, no active work
* - "streaming"→ THINKING — LLM call in flight, tokens streaming
* - "error" → ERROR — agent encountered an unhandled error
* - (no session) → IDLE — no active session exists for this agent
*/
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error';
/**
* Extended status including offline — not all agents have active sessions.
* Used internally; the UI only sees AgentStatus (offline maps to idle).
*/
export type AgentLifecycleStatus = AgentStatus | 'offline';
// ─── Agent Card Data ───────────────────────────────────────────────────────
/**
* The primary data shape consumed by Agent Card components in the Command Hub.
* Matches the TypeScript interface from the design spec exactly.
*/
export interface AgentCardData {
/** Agent identifier, e.g. "otto", "dex", "hex" */
id: string;
/** Human-readable name, e.g. "Otto", "Dex" */
displayName: string;
/** Role description from agent identity, e.g. "Orchestrator Agent" */
role: string;
/** Current operational status */
status: AgentStatus;
/** Description of the current task, if any */
currentTask?: string;
/** Task progress percentage (0100), if trackable */
taskProgress?: number;
/** Elapsed time string, e.g. "04m 12s" */
taskElapsed?: string;
/** Full session key, e.g. "agent:otto:telegram:direct:8787451565" */
sessionKey: string;
/** Channel the agent is operating on, e.g. "telegram", "discord" */
channel: string;
/** ISO timestamp of last activity */
lastActivity: string;
/** Error message when status is 'error' */
errorMessage?: string;
}
// ─── OpenClaw Gateway Primitives ───────────────────────────────────────────
/**
* Raw agent configuration from OpenClaw's config (openclaw.json agents.list[]).
* Not all fields are always present — this is a partial reflection.
*/
export interface OpenClawAgentConfig {
id: string;
name?: string;
emoji?: string;
avatar?: string;
model?: string | { primary: string };
workspace?: string;
agentDir?: string;
identity?: {
name?: string;
emoji?: string;
avatar?: string;
theme?: string;
};
routing?: Array<{
channel: string;
account?: string;
peer?: string;
group?: string;
}>;
subagents?: {
allowAgents?: string[];
maxChildrenPerAgent?: number;
};
}
/**
* Session entry returned by `sessions.list` RPC or `/tools/invoke sessions_list`.
* This is the raw shape from the Gateway.
*/
export interface OpenClawSession {
key: string;
kind: string;
channel: string;
origin?: {
provider: string;
accountId?: string;
peerId?: string;
};
displayName?: string;
deliveryContext?: {
to?: string;
channel?: string;
accountId?: string;
peerId?: string;
};
updatedAt: number; // epoch ms
sessionId: string; // UUID
model?: string;
modelProvider?: string;
contextTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
estimatedCostUsd?: number;
status?: 'done' | 'running' | 'streaming' | 'error' | 'aborted';
startedAt?: number; // epoch ms
endedAt?: number; // epoch ms
runtimeMs?: number;
systemSent?: boolean;
abortedLastRun?: boolean;
lastTo?: string;
label?: string;
transcriptPath?: string;
agentId?: string;
inputTokens?: number;
outputTokens?: number;
providerOverride?: string;
modelOverride?: string;
}
/**
* Gateway health response from `/health` endpoint.
*/
export interface GatewayHealth {
ok: boolean;
status: 'live' | 'stale' | 'down';
}
/**
* Identity payload for an agent, returned by `agent.identity.get` RPC.
*/
export interface AgentIdentity {
agentId: string;
name: string;
emoji?: string;
avatar?: string;
theme?: string;
description?: string;
}
// ─── WebSocket Protocol Frames ─────────────────────────────────────────────
/**
* Base frame shape for the OpenClaw Gateway WebSocket protocol v3.
* - Request: { type: "req", id, method, params }
* - Response: { type: "res", id, ok, payload|error }
* - Event: { type: "event", event, payload, seq?, stateVersion? }
*/
export type WsFrame =
| WsRequest
| WsResponse
| WsEvent;
export interface WsRequest {
type: 'req';
id: string;
method: string;
params: Record<string, unknown>;
}
export interface WsResponse {
type: 'res';
id: string;
ok: boolean;
payload?: unknown;
error?: {
type: string;
message: string;
details?: Record<string, unknown>;
};
}
export interface WsEvent {
type: 'event';
event: string;
payload: unknown;
seq?: number;
stateVersion?: number;
}
// ─── Event Payloads ────────────────────────────────────────────────────────
/**
* Event names emitted by the Gateway that the Command Hub subscribes to.
*/
export type GatewayEventName =
| 'sessions.changed'
| 'session.message'
| 'session.tool'
| 'presence'
| 'health'
| 'tick'
| 'heartbeat'
| 'cron'
| 'shutdown'
| 'exec.approval.requested'
| 'exec.approval.resolved'
| 'node.pair.requested'
| 'node.pair.resolved'
| 'chat'
| 'device.pair.requested'
| 'device.pair.resolved';
/**
* sessions.changed event payload — broadcast when the session index updates.
*/
export interface SessionsChangedPayload {
added?: string[]; // session keys added
removed?: string[]; // session keys removed
updated?: string[]; // session keys with metadata changes
snapshot?: OpenClawSession[];
}
/**
* session.message event payload — transcript message for a subscribed session.
*/
export interface SessionMessagePayload {
sessionKey: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
timestamp: number;
toolCalls?: ToolCallInfo[];
}
/**
* session.tool event payload — tool execution event for a subscribed session.
*/
export interface SessionToolPayload {
sessionKey: string;
toolName: string;
status: 'started' | 'completed' | 'error';
duration?: number;
result?: string;
error?: string;
}
/**
* Presence event payload — devices connected to the gateway.
*/
export interface PresencePayload {
devices: Array<{
deviceId: string;
roles: string[];
scopes: string[];
connected: boolean;
lastSeen?: number;
}>;
}
/**
* Health event payload — gateway health snapshot update.
*/
export interface HealthEventPayload {
ok: boolean;
status: string;
uptime?: number;
agentsOnline?: number;
}
/**
* Simplified tool call info extracted from transcript events.
*/
export interface ToolCallInfo {
name: string;
status: 'started' | 'completed' | 'error';
args?: Record<string, unknown>;
result?: string;
error?: string;
}
// ─── API Request/Response Types ────────────────────────────────────────────
/**
* Tools invoke request body for the HTTP `/tools/invoke` endpoint.
*/
export interface ToolsInvokeRequest {
tool: string;
action?: string;
args?: Record<string, unknown>;
sessionKey?: string;
dryRun?: boolean;
}
/**
* Tools invoke success response.
*/
export interface ToolsInvokeResponse<T = unknown> {
ok: true;
result: {
content?: Array<{ type: string; text: string }>;
details?: T;
};
}
/**
* Tools invoke error response.
*/
export interface ToolsInvokeErrorResponse {
ok: false;
error: {
type: string;
message: string;
};
}
// ─── Connect Handshake ────────────────────────────────────────────────────
/**
* Connect request params for the WebSocket handshake.
*/
export interface ConnectParams {
minProtocol: number;
maxProtocol: number;
client: {
id: string;
version: string;
platform: string;
mode: 'operator' | 'node';
};
role: 'operator' | 'node';
scopes: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
auth: {
token?: string;
password?: string;
deviceToken?: string;
};
locale?: string;
userAgent?: string;
device?: {
id: string;
publicKey?: string;
signature?: string;
signedAt?: number;
nonce?: string;
};
}
/**
* Hello-ok response payload after successful connect.
*/
export interface HelloOkPayload {
type: 'hello-ok';
protocol: number;
policy: {
tickIntervalMs: number;
};
auth?: {
deviceToken?: string;
role: string;
scopes: string[];
deviceTokens?: Array<{
deviceToken: string;
role: string;
scopes: string[];
}>;
};
}
/**
* Connect challenge event from the gateway.
*/
export interface ConnectChallengePayload {
nonce: string;
ts: number;
}
// ─── Agent Role Map ────────────────────────────────────────────────────────
/**
* Known agent roles. This maps agent IDs to their functional descriptions
* for display in the Command Hub. Can be overridden by agent identity config.
*/
export const AGENT_ROLES: Record<string, string> = {
main: 'Primary Assistant',
otto: 'Orchestrator Agent',
dave: 'Network Admin Agent',
bob: 'Content Writer Agent',
stuart: 'Image & Creative Agent',
phil: 'Home Automation Agent',
carl: 'Security Agent',
larry: 'Business Agent',
mel: 'E-Commerce Agent',
norbert: 'Product Agent',
jerry: 'Market Research Agent',
rex: 'Frontend Dev Agent',
dex: 'Backend Dev Agent',
hex: 'Database Agent',
pip: 'Raspberry Pi Agent',
nano: 'ESP32/Firmware Agent',
axiom: 'Utility Agent',
bonnie: 'Music Agent',
sketch: 'UI/UX Design Agent',
flip: 'Mobile Dev Agent',
buzz: 'SEO Agent',
aries: 'Companion Agent',
};

View File

@@ -0,0 +1,272 @@
/**
* @fileoverview OpenClaw Gateway HTTP API client.
*
* Wraps the Gateway's REST surface:
* - `GET /health` — gateway health check
* - `POST /tools/invoke` — invoke a tool directly via HTTP
* - `GET /v1/models` — OpenAI-compatible model list (if enabled)
*
* The primary data-fetching path is `/tools/invoke`, which provides access to:
* - `sessions_list` — list active sessions across agents
* - `agents_list` — list configured agents
* - `linear_*` — Linear issue management (if plugin enabled)
*
* Note: Many WS RPC methods are NOT available over HTTP due to the built-in
* deny list (exec, spawn, shell, fs_write, sessions_spawn, etc.).
* For full Gateway access, use the WebSocket client.
*/
import { OpenClawClientConfig } from '../utils/config';
import type {
OpenClawSession,
OpenClawAgentConfig,
GatewayHealth,
ToolsInvokeRequest,
ToolsInvokeResponse,
ToolsInvokeErrorResponse,
} from '../models/types';
/**
* Parsed session list from the tools invoke response.
*/
export interface SessionListResult {
count: number;
sessions: OpenClawSession[];
}
/**
* Parsed agent list from the tools invoke response.
*/
export interface AgentListResult {
requester: string;
allowAny: boolean;
agents: Array<{ id: string; configured: boolean }>;
}
/**
* Event types for the HTTP client.
*/
export type HttpClientEvent =
| { type: 'request-sent'; method: string; url: string }
| { type: 'response-received'; method: string; url: string; status: number }
| { type: 'error'; method: string; url: string; error: Error };
export class OpenClawHttpClient {
private readonly baseUrl: string;
private readonly authToken: string;
private readonly authMode: string;
private readonly timeoutMs: number;
constructor(private readonly config: OpenClawClientConfig) {
this.baseUrl = config.gatewayUrl.replace(/\/+$/, '');
this.authToken = config.authToken;
this.authMode = config.authMode;
this.timeoutMs = config.httpTimeoutMs;
}
// ─── Health ───────────────────────────────────────────────────────────
/**
* Check gateway health. This is the simplest endpoint — no auth required.
* `GET /health`
*/
async getHealth(): Promise<GatewayHealth> {
return this.get<GatewayHealth>('/health');
}
// ─── Tools Invoke ────────────────────────────────────────────────────
/**
* Invoke a tool via the HTTP endpoint.
* `POST /tools/invoke`
*
* This is the primary way to fetch data without a WebSocket connection.
* Available tools are filtered by the Gateway's HTTP deny list.
*/
async invokeTool<T = unknown>(
tool: string,
args: Record<string, unknown> = {},
sessionKey?: string
): Promise<T> {
const body: ToolsInvokeRequest = {
tool,
args,
...(sessionKey ? { sessionKey } : {}),
};
const response = await this.post<ToolsInvokeResponse<T> | ToolsInvokeErrorResponse>(
'/tools/invoke',
body
);
if (!response.ok) {
const err = response as ToolsInvokeErrorResponse;
throw new Error(`Tool invoke failed: ${err.error.type}${err.error.message}`);
}
const success = response as ToolsInvokeResponse<T>;
return (success.result.details ?? success.result.content) as T;
}
// ─── Session Queries ─────────────────────────────────────────────────
/**
* List sessions across all agents via `/tools/invoke`.
* Maps to the `sessions_list` tool.
*
* @param activeMinutes Only return sessions updated within the last N minutes
* @param allAgents Aggregate across all configured agents
*/
async listSessions(
activeMinutes?: number,
allAgents: boolean = true
): Promise<SessionListResult> {
const args: Record<string, unknown> = { allAgents };
if (activeMinutes !== undefined) {
args.activeMinutes = activeMinutes;
}
const raw = await this.invokeTool<SessionListResult>('sessions_list', args);
// The tools invoke response wraps session data in `details` or `content`
// Normalize it here
if (raw && typeof raw === 'object' && 'sessions' in raw) {
return raw as SessionListResult;
}
// Fallback: try to parse from content text
const contentResult = await this.invokeTool<{ content?: Array<{ type: string; text: string }> }>(
'sessions_list',
args
);
if (contentResult?.content?.[0]?.text) {
return JSON.parse(contentResult.content[0].text) as SessionListResult;
}
return { count: 0, sessions: [] };
}
/**
* Get sessions for a specific agent.
*/
async listAgentSessions(
agentId: string,
activeMinutes?: number
): Promise<SessionListResult> {
const args: Record<string, unknown> = { allAgents: false, agentId };
if (activeMinutes !== undefined) {
args.activeMinutes = activeMinutes;
}
const raw = await this.invokeTool<SessionListResult>('sessions_list', args);
if (raw && typeof raw === 'object' && 'sessions' in raw) {
return raw as SessionListResult;
}
return { count: 0, sessions: [] };
}
// ─── Agent Queries ───────────────────────────────────────────────────
/**
* List configured agents via `/tools/invoke`.
* Maps to the `agents_list` tool.
*
* Note: This only returns agents the current session's policy allows.
* For the full agent list, prefer reading from config or using the WS protocol.
*/
async listAgents(): Promise<AgentListResult> {
return this.invokeTool<AgentListResult>('agents_list', {});
}
// ─── Model Queries ──────────────────────────────────────────────────
/**
* List available models via the OpenAI-compatible endpoint.
* Requires `gateway.http.endpoints.chatCompletions.enabled: true`.
* `GET /v1/models`
*/
async listModels(): Promise<unknown> {
return this.get('/v1/models');
}
// ─── HTTP Primitives ─────────────────────────────────────────────────
private async get<T>(path: string): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Accept': 'application/json',
};
if (this.authMode === 'token' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
} else if (this.authMode === 'password' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Request timeout (${this.timeoutMs}ms): GET ${path}`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
private async post<T>(path: string, body: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (this.authMode === 'token' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
} else if (this.authMode === 'password' && this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${response.statusText}${text}`);
}
return response.json() as Promise<T>;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Request timeout (${this.timeoutMs}ms): POST ${path}`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* @fileoverview Configuration for the OpenClaw API client.
*
* Centralizes gateway URL, auth token, WebSocket options, and polling intervals.
* Designed for Angular environment injection — values can be overridden at build time
* or via Angular's environment.ts files.
*/
export interface OpenClawClientConfig {
/** Gateway HTTP base URL, e.g. "http://10.60.1.145:18789" */
gatewayUrl: string;
/** Gateway WebSocket URL, e.g. "ws://10.60.1.145:18789" */
gatewayWsUrl: string;
/** Auth token (from OPENCLAW_GATEWAY_TOKEN or configured secret) */
authToken: string;
/** Auth mode — must match gateway.auth.mode in openclaw.json */
authMode: 'token' | 'password' | 'none' | 'trusted-proxy';
/** How often (ms) to poll for session status when WS is not connected */
pollIntervalMs: number;
/** How often (ms) to poll for agent list refresh */
agentListPollMs: number;
/** WebSocket reconnect delay (ms), with exponential backoff */
wsReconnectDelayMs: number;
/** Maximum WebSocket reconnect attempts before giving up */
wsMaxReconnectAttempts: number;
/** Request timeout for HTTP calls (ms) */
httpTimeoutMs: number;
/** Protocol version for WS handshake */
protocolVersion: number;
/** Client identity for WS handshake */
clientId: string;
/** Client version for WS handshake */
clientVersion: string;
/** Client platform for WS handshake */
clientPlatform: string;
}
/**
* Default configuration — targets the LAN-bound gateway on port 18789.
* Override specific fields via Angular environment injection.
*/
export const DEFAULT_CONFIG: OpenClawClientConfig = {
gatewayUrl: 'http://10.60.1.145:18789',
gatewayWsUrl: 'ws://10.60.1.145:18789',
authToken: '',
authMode: 'token',
pollIntervalMs: 5_000,
agentListPollMs: 30_000,
wsReconnectDelayMs: 1_000,
wsMaxReconnectAttempts: 50,
httpTimeoutMs: 15_000,
protocolVersion: 3,
clientId: 'control-center',
clientVersion: '0.1.0',
clientPlatform: 'web',
};
/**
* Create a resolved config by merging partial overrides onto defaults.
*/
export function createConfig(overrides: Partial<OpenClawClientConfig> = {}): OpenClawClientConfig {
return { ...DEFAULT_CONFIG, ...overrides };
}

View File

@@ -0,0 +1,6 @@
/**
* @fileoverview Re-export barrel for utilities.
*/
export * from './config';
export * from './status-mapper';

View File

@@ -0,0 +1,178 @@
/**
* @fileoverview Status mapping utilities.
*
* Translates OpenClaw Gateway session statuses and timestamps
* into the AgentStatus enum and elapsed-time strings used by AgentCardData.
*/
import { AgentStatus, AgentLifecycleStatus, OpenClawSession, AGENT_ROLES } from '../models/types';
/**
* Map an OpenClaw session status to an AgentStatus for the UI.
*
* Mapping logic:
* - "running" or "streaming" with no tokens yet → ACTIVE (agent processing turn)
* - "streaming" with output tokens → THINKING (LLM call in flight)
* - "done" → IDLE (agent finished, waiting for next input)
* - "error" → ERROR
* - "aborted" → IDLE (abort = intentional stop, not an error)
* - null/undefined → IDLE (no active session = idle)
*/
export function mapSessionStatusToAgentStatus(sessionStatus?: string | null): AgentStatus {
switch (sessionStatus) {
case 'running':
return 'active';
case 'streaming':
return 'thinking';
case 'done':
return 'idle';
case 'error':
return 'error';
case 'aborted':
return 'idle';
default:
return 'idle';
}
}
/**
* Determine lifecycle status for an agent that may not have an active session.
* If no session exists, the agent is considered "offline".
*/
export function determineLifecycleStatus(
session?: OpenClawSession | null
): AgentLifecycleStatus {
if (!session) return 'offline';
return mapSessionStatusToAgentStatus(session.status);
}
/**
* Convert an OpenClaw session to the lifecycle status used by the UI.
* Offline agents are presented as "idle" to the UI.
*/
export function lifecycleToUiStatus(lifecycle: AgentLifecycleStatus): AgentStatus {
return lifecycle === 'offline' ? 'idle' : lifecycle;
}
/**
* Format elapsed time from a session's startedAt timestamp.
* Returns a human-readable string like "04m 12s" or "1h 23m".
*
* If the session hasn't started or is done, returns undefined.
*/
export function formatElapsedTime(
session?: OpenClawSession | null,
nowMs?: number
): string | undefined {
if (!session?.startedAt) return undefined;
const endTime = session.endedAt || nowMs || Date.now();
const elapsedMs = endTime - session.startedAt;
if (elapsedMs < 0) return undefined;
const totalSeconds = Math.floor(elapsedMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
}
return `${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
}
/**
* Extract the channel name from a session key.
* Session key format: "agent:<agentId>:<channel>:<kind>:<peerId>"
* Or from the session's channel/origin fields.
*/
export function extractChannelFromSession(session: OpenClawSession): string {
// Prefer explicit channel field
if (session.channel && session.channel !== 'heartbeat') {
return session.channel;
}
// Fall back to origin provider
if (session.origin?.provider) {
return session.origin.provider;
}
// Parse session key: "agent:otto:telegram:direct:8787451565"
const parts = session.key.split(':');
if (parts.length >= 3 && parts[0] === 'agent') {
return parts[2];
}
return 'unknown';
}
/**
* Extract the agent ID from a session key.
* Session key format: "agent:<agentId>:..."
*/
export function extractAgentIdFromSessionKey(sessionKey: string): string {
const parts = sessionKey.split(':');
if (parts[0] === 'agent' && parts.length >= 2) {
return parts[1];
}
return sessionKey;
}
/**
* Get the role description for an agent ID.
* Falls back to AGENT_ROLES map, then to a generic string.
*/
export function getAgentRole(agentId: string): string {
return AGENT_ROLES[agentId] || `${agentId} Agent`;
}
/**
* Estimate task progress based on session state.
*
* OpenClaw doesn't natively expose a 0100 progress percentage.
* We derive an approximation:
* - "running" session → 1050% (early in the task, we don't know)
* - "streaming" → 6090% (LLM is responding)
* - "done" → 100%
* - "error" → whatever progress was made before the error
*
* This is intentionally coarse. A future enhancement could parse
* sub-agent progress or tool call completion ratios.
*/
export function estimateTaskProgress(session?: OpenClawSession | null): number | undefined {
if (!session) return undefined;
switch (session.status) {
case 'running':
return 25; // processing, early stage
case 'streaming':
return 75; // LLM responding
case 'done':
return 100;
case 'error':
return undefined; // unknown how far it got
case 'aborted':
return undefined;
default:
return undefined;
}
}
/**
* Derive a current-task description from session metadata.
* Uses the session label (for cron sessions) or constructs a generic description.
*/
export function deriveCurrentTask(session?: OpenClawSession | null): string | undefined {
if (!session) return undefined;
// Cron sessions have a label
if (session.label) return session.label;
// Sessions with displayName that isn't generic
if (session.displayName && session.displayName !== 'direct' && session.displayName !== 'heartbeat') {
return session.displayName;
}
// Active sessions get a generic description
if (session.status === 'running' || session.status === 'streaming') {
return 'Processing task...';
}
return undefined;
}

View File

@@ -0,0 +1,5 @@
/**
* @fileoverview Re-export barrel for WebSocket module.
*/
export * from './ws-client';

View File

@@ -0,0 +1,584 @@
/**
* @fileoverview OpenClaw Gateway WebSocket client.
*
* Implements the Gateway WS protocol v3:
* 1. Connect and receive `connect.challenge`
* 2. Send `connect` request with auth
* 3. Receive `hello-ok` (or error)
* 4. Subscribe to session events (`sessions.subscribe`, `sessions.messages.subscribe`)
* 5. Listen for real-time events (session changes, messages, tool calls, presence)
* 6. Send RPC requests (agents.list, sessions.list, sessions.abort, etc.)
*
* Uses RxJS subjects for reactive event streaming — the Angular frontend
* can subscribe to specific event types without callback hell.
*/
import { Subject, Observable, BehaviorSubject, Subscription } from 'rxjs';
import { filter, map, share } from 'rxjs/operators';
import {
WsFrame,
WsRequest,
WsResponse,
WsEvent,
ConnectParams,
HelloOkPayload,
ConnectChallengePayload,
GatewayEventName,
SessionsChangedPayload,
SessionMessagePayload,
SessionToolPayload,
PresencePayload,
HealthEventPayload,
OpenClawSession,
} from '../models/types';
import { OpenClawClientConfig } from '../utils/config';
// ─── Connection State ──────────────────────────────────────────────────────
export type WsConnectionState =
| 'disconnected'
| 'connecting'
| 'challenged'
| 'authenticating'
| 'connected'
| 'reconnecting'
| 'error';
export interface WsConnectionInfo {
state: WsConnectionState;
protocol?: number;
sessionId?: string;
reconnectAttempt?: number;
lastError?: string;
}
// ─── RPC Pending Request ──────────────────────────────────────────────────
interface PendingRpc {
resolve: (payload: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
}
// ─── Client ───────────────────────────────────────────────────────────────
export class OpenClawWebSocketClient {
private ws: WebSocket | null = null;
private rpcId = 0;
private pendingRpc = new Map<string, PendingRpc>();
private challengeNonce = '';
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private subscriptions = new Set<string>(); // session keys we've subscribed to
private sessionSubscriptionsActive = false;
// ─── RxJS Streams ────────────────────────────────────────────────────
/** All raw WebSocket events */
private readonly rawEvent$ = new Subject<WsEvent>();
/** Connection state changes */
readonly connectionState$ = new BehaviorSubject<WsConnectionInfo>({
state: 'disconnected',
});
/** Session index changed events */
readonly sessionsChanged$ = this.rawEvent$.pipe(
filter((e) => e.event === 'sessions.changed'),
map((e) => e.payload as SessionsChangedPayload),
share()
);
/** Session message events (for subscribed sessions) */
readonly sessionMessage$ = this.rawEvent$.pipe(
filter((e) => e.event === 'session.message'),
map((e) => e.payload as SessionMessagePayload),
share()
);
/** Session tool events (for subscribed sessions) */
readonly sessionTool$ = this.rawEvent$.pipe(
filter((e) => e.event === 'session.tool'),
map((e) => e.payload as SessionToolPayload),
share()
);
/** Presence events */
readonly presence$ = this.rawEvent$.pipe(
filter((e) => e.event === 'presence'),
map((e) => e.payload as PresencePayload),
share()
);
/** Health events */
readonly health$ = this.rawEvent$.pipe(
filter((e) => e.event === 'health'),
map((e) => e.payload as HealthEventPayload),
share()
);
/** Tick/keepalive events */
readonly tick$ = this.rawEvent$.pipe(
filter((e) => e.event === 'tick'),
share()
);
/** Gateway shutdown notification */
readonly shutdown$ = this.rawEvent$.pipe(
filter((e) => e.event === 'shutdown'),
share()
);
/** Any event with a specific name */
on(event: GatewayEventName): Observable<unknown> {
return this.rawEvent$.pipe(
filter((e) => e.event === event),
map((e) => e.payload)
);
}
// ─── Constructor ─────────────────────────────────────────────────────
constructor(private readonly config: OpenClawClientConfig) {}
// ─── Connection Lifecycle ───────────────────────────────────────────
/**
* Connect to the Gateway WebSocket.
* Handles the full handshake: challenge → connect → hello-ok.
*/
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
return; // already connected
}
this.updateState({ state: 'connecting' });
const url = this.config.gatewayWsUrl;
try {
this.ws = new WebSocket(url);
} catch (err) {
this.updateState({ state: 'error', lastError: `Failed to create WebSocket: ${err}` });
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
// Waiting for challenge from gateway
this.updateState({ state: 'challenged' });
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onerror = (event) => {
this.updateState({
state: 'error',
lastError: `WebSocket error: ${event.type}`,
});
};
this.ws.onclose = (event) => {
this.updateState({
state: 'disconnected',
lastError: event.reason || `WebSocket closed (code: ${event.code})`,
});
this.clearPendingRpcs(new Error(`WebSocket closed: ${event.code}`));
this.scheduleReconnect();
};
}
/**
* Disconnect from the Gateway WebSocket.
* Cancels reconnect timer and cleans up.
*/
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.reconnectAttempt = 0;
if (this.ws) {
this.ws.onclose = null; // prevent reconnect
this.ws.close(1000, 'Client disconnect');
this.ws = null;
}
this.clearPendingRpcs(new Error('Client disconnected'));
this.updateState({ state: 'disconnected' });
}
/**
* Check if the WebSocket is currently connected.
*/
get isConnected(): boolean {
return this.connectionState$.value.state === 'connected';
}
// ─── RPC Methods ─────────────────────────────────────────────────────
/**
* Send an RPC request and wait for the response.
* Returns a promise that resolves with the response payload
* or rejects on error/timeout.
*
* @param method RPC method name (e.g. "sessions.list", "agents.list")
* @param params Method parameters
* @param timeoutMs Optional timeout override
*/
async rpc<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs?: number
): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}
const id = `cc-${++this.rpcId}`;
const request: WsRequest = {
type: 'req',
id,
method,
params,
};
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRpc.delete(id);
reject(new Error(`RPC timeout: ${method} (${timeoutMs || this.config.httpTimeoutMs}ms)`));
}, timeoutMs || this.config.httpTimeoutMs);
this.pendingRpc.set(id, {
resolve: resolve as (p: unknown) => void,
reject,
timeoutId: timeout,
});
this.ws!.send(JSON.stringify(request));
});
}
// ─── High-Level API ─────────────────────────────────────────────────
/**
* Subscribe to session change events for the current WS client.
* After subscribing, `sessions.changed` events will be emitted.
*/
async subscribeToSessions(): Promise<void> {
if (this.sessionSubscriptionsActive) return;
await this.rpc('sessions.subscribe', {});
this.sessionSubscriptionsActive = true;
}
/**
* Unsubscribe from session change events.
*/
async unsubscribeFromSessions(): Promise<void> {
if (!this.sessionSubscriptionsActive) return;
await this.rpc('sessions.unsubscribe', {});
this.sessionSubscriptionsActive = false;
}
/**
* Subscribe to message/transcript events for a specific session.
* Required before session.message and session.tool events fire.
*
* @param sessionKey Full session key, e.g. "agent:otto:telegram:direct:8787451565"
*/
async subscribeToSessionMessages(sessionKey: string): Promise<void> {
if (this.subscriptions.has(sessionKey)) return;
await this.rpc('sessions.messages.subscribe', { sessionKey });
this.subscriptions.add(sessionKey);
}
/**
* Unsubscribe from message events for a session.
*/
async unsubscribeFromSessionMessages(sessionKey: string): Promise<void> {
if (!this.subscriptions.has(sessionKey)) return;
await this.rpc('sessions.messages.unsubscribe', { sessionKey });
this.subscriptions.delete(sessionKey);
}
/**
* Fetch the current session list via WS RPC.
* More complete than the HTTP tools/invoke version.
*/
async fetchSessions(allAgents = true, activeMinutes?: number): Promise<OpenClawSession[]> {
const params: Record<string, unknown> = { allAgents };
if (activeMinutes !== undefined) {
params.activeMinutes = activeMinutes;
}
const result = await this.rpc<{ sessions: OpenClawSession[]; count: number }>(
'sessions.list',
params
);
return result.sessions || [];
}
/**
* Fetch the agent list via WS RPC.
*/
async fetchAgents(): Promise<Array<{ id: string; configured: boolean }>> {
const result = await this.rpc<{ agents: Array<{ id: string; configured: boolean }> }>(
'agents.list',
{}
);
return result.agents || [];
}
/**
* Fetch agent identity via WS RPC.
*/
async fetchAgentIdentity(agentId: string): Promise<unknown> {
return this.rpc('agent.identity.get', { agentId });
}
/**
* Get a transcript preview for a session.
*/
async fetchSessionPreview(
sessionKey: string,
limit = 20
): Promise<unknown> {
return this.rpc('sessions.preview', { sessionKey, limit });
}
/**
* Abort an active session.
*/
async abortSession(sessionKey: string): Promise<void> {
await this.rpc('sessions.abort', { sessionKey });
}
/**
* Steer (interrupt + inject message) an active session.
*/
async steerSession(sessionKey: string, message: string): Promise<void> {
await this.rpc('sessions.steer', { sessionKey, message });
}
/**
* Fetch gateway status (admin-scoped).
*/
async fetchStatus(): Promise<unknown> {
return this.rpc('status', {});
}
/**
* Fetch gateway health snapshot.
*/
async fetchHealth(): Promise<unknown> {
return this.rpc('health', {});
}
/**
* Fetch system presence.
*/
async fetchPresence(): Promise<unknown> {
return this.rpc('system-presence', {});
}
/**
* Fetch channel status for all configured channels.
*/
async fetchChannelStatus(): Promise<unknown> {
return this.rpc('channels.status', {});
}
/**
* Fetch log tail from the gateway.
*/
async fetchLogTail(
cursor?: string,
limit?: number,
maxBytes?: number
): Promise<unknown> {
const params: Record<string, unknown> = {};
if (cursor) params.cursor = cursor;
if (limit) params.limit = limit;
if (maxBytes) params.maxBytes = maxBytes;
return this.rpc('logs.tail', params);
}
// ─── Internal Handlers ──────────────────────────────────────────────
private handleMessage(data: string): void {
let frame: WsFrame;
try {
frame = JSON.parse(data);
} catch {
console.warn('[OpenClaw WS] Failed to parse frame:', data);
return;
}
switch (frame.type) {
case 'event':
this.handleEvent(frame as WsEvent);
break;
case 'res':
this.handleResponse(frame as WsResponse);
break;
default:
console.warn('[OpenClaw WS] Unknown frame type:', frame.type);
}
}
private handleEvent(event: WsEvent): void {
// Handle connect challenge specially
if (event.event === 'connect.challenge') {
this.handleChallenge(event.payload as ConnectChallengePayload);
return;
}
// Emit to all subscribers
this.rawEvent$.next(event);
}
private handleChallenge(challenge: ConnectChallengePayload): void {
this.challengeNonce = challenge.nonce;
this.updateState({ state: 'authenticating' });
// Send connect request
const connectParams: ConnectParams = {
minProtocol: this.config.protocolVersion,
maxProtocol: this.config.protocolVersion,
client: {
id: this.config.clientId,
version: this.config.clientVersion,
platform: this.config.clientPlatform,
mode: 'operator',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
auth: {
token: this.config.authToken,
},
locale: 'en-US',
userAgent: `control-center/${this.config.clientVersion}`,
};
this.sendRequest('connect', connectParams);
}
private handleResponse(response: WsResponse): void {
// Handle hello-ok (connect response)
if (response.id && response.id.startsWith('cc-')) {
const pending = this.pendingRpc.get(response.id);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingRpc.delete(response.id);
if (response.ok) {
pending.resolve(response.payload);
} else {
pending.reject(
new Error(
`RPC error: ${response.error?.type || 'unknown'}${response.error?.message || 'no details'}`
)
);
}
return;
}
}
// Handle connect response (sent without cc- prefix)
if (response.ok && response.payload) {
const payload = response.payload as HelloOkPayload;
if (payload.type === 'hello-ok') {
this.reconnectAttempt = 0;
this.updateState({
state: 'connected',
protocol: payload.protocol,
});
// Auto-subscribe to session changes
this.subscribeToSessions().catch((err) => {
console.warn('[OpenClaw WS] Failed to subscribe to sessions:', err);
});
return;
}
}
// Handle connect error
if (!response.ok && response.error) {
this.updateState({
state: 'error',
lastError: `Connect failed: ${response.error.type}${response.error.message}`,
});
this.scheduleReconnect();
}
}
private sendRequest(method: string, params: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
const request: WsRequest = {
type: 'req',
id: `rpc-${++this.rpcId}`,
method,
params,
};
this.ws.send(JSON.stringify(request));
}
private updateState(partial: Partial<WsConnectionInfo>): void {
const current = this.connectionState$.value;
this.connectionState$.next({ ...current, ...partial } as WsConnectionInfo);
}
private scheduleReconnect(): void {
if (this.reconnectAttempt >= this.config.wsMaxReconnectAttempts) {
this.updateState({
state: 'error',
lastError: `Max reconnect attempts reached (${this.config.wsMaxReconnectAttempts})`,
});
return;
}
this.reconnectAttempt++;
const delay = Math.min(
this.config.wsReconnectDelayMs * Math.pow(1.5, this.reconnectAttempt - 1),
30_000 // cap at 30s
);
this.updateState({
state: 'reconnecting',
reconnectAttempt: this.reconnectAttempt,
});
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
private clearPendingRpcs(error: Error): void {
for (const [id, pending] of this.pendingRpc.entries()) {
clearTimeout(pending.timeoutId);
pending.reject(error);
}
this.pendingRpc.clear();
}
/**
* Clean up all subscriptions and disconnect.
*/
destroy(): void {
this.disconnect();
this.rawEvent$.complete();
this.connectionState$.complete();
this.subscriptions.clear();
this.sessionSubscriptionsActive = false;
}
}

21
api-client/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

511
design/command-hub-spec.md Normal file
View File

@@ -0,0 +1,511 @@
# OpenClaw Control Center — Command Hub
## Senior Product Designer Specification (7-Point)
**Version:** 1.0
**Date:** 2026-04-21
**Designer:** Sketch (UI/UX Agent)
**Status:** Implementation-Ready
**Design System:** Material Design 3 (https://m3.material.io/)
**Aesthetic:** Tactical Dark Mode
---
## 1. Objective
Design the **Command Hub** — the primary entry point and nerve centre for the OpenClaw Control Center. This screen enables operators to **monitor all active agents at a glance**, assess fleet health in under 3 seconds, and **jump directly into any agent session** without friction.
The design must serve two primary mental models:
- **Triage Mode** — scan for errors, stalls, and anomalies fast
- **Navigation Mode** — get into a specific agent session instantly
This is a utility-first, operator-grade interface. It is not a marketing page. Every pixel earns its place.
---
## 2. Screen Inventory
| Screen | Description | Trigger |
|--------|-------------|---------|
| **Command Hub** | Fleet Status grid + global nav | App launch / Home |
| **Agent Session View** | Full session detail for one agent | Quick-Jump tap |
| **Session Log** | Full scrollable log history | Long-press agent card or log icon |
| **Task Breakdown** | Subtask waterfall for active task | Tap active task chip on card |
| **Settings / Config** | Global config, theme, agents list | Nav Rail → Settings |
| **Notifications Panel** | Alert history and system events | Bell icon / swipe |
---
## 3. Layout Specification
### 3.1 Kiosk Layout (≥ 1024px — Fixed Display / Raspberry Pi HDMI)
```
┌─────────────────────────────────────────────────────────────────┐
│ NAV RAIL (72px) │ MAIN CONTENT AREA │
│ │ │
│ [☰] OpenClaw │ ┌─ HEADER BAR ─────────────────────────┐ │
│ │ │ "Command Hub" [🔔 3] [●Live] [⚙] │ │
│ [⚡] Command Hub │ └──────────────────────────────────────┘ │
│ [📋] Projects │ │
│ [🗂] Sessions │ ┌─ FLEET STATUS ────────────────────────┐ │
│ [📊] Logs │ │ FILTER: [All ▾] [Active] [Error] │ │
│ ───────── │ │ │ │
│ [⚙] Settings │ │ ┌──────────┐ ┌──────────┐ ┌──────┐ │ │
│ [👤] Profile │ │ │ AGENT │ │ AGENT │ │AGENT │ │ │
│ │ │ │ CARD │ │ CARD │ │ CARD │ │ │
│ │ │ └──────────┘ └──────────┘ └──────┘ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ... │ │
│ │ │ │ AGENT │ │ AGENT │ │ │
│ │ │ │ CARD │ │ CARD │ │ │
│ │ │ └──────────┘ └──────────┘ │ │
│ │ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
**Nav Rail:** 72px collapsed, 256px expanded. M3 `NavigationRail` component. Always visible on kiosk.
**Main Content:** 3-column agent card grid (CSS Grid, `repeat(auto-fill, minmax(340px, 1fr))`). 24px gutter.
**Header Bar:** 64px tall. App title + live indicator + notification bell + settings.
---
### 3.2 Mobile Layout (< 768px — Phone / Tablet)
```
┌───────────────────────────────┐
│ ┌─ HEADER ──────────────────┐│
│ │ ≡ Command Hub 🔔 ⚙ ││
│ └────────────────────────────│
│ │
│ [All ▾] [Active 4] [Error 1] │
│ │
│ ┌──────────────────────────┐ │
│ │ AGENT CARD (full width) │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ AGENT CARD (full width) │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ AGENT CARD (full width) │ │
│ └──────────────────────────┘ │
│ │
│ ───────────────────────────── │
│ [⚡Hub] [📋Projects] [📊Logs] │
│ BOTTOM NAV BAR │
└───────────────────────────────┘
```
**Bottom Navigation:** M3 `NavigationBar`. 35 destinations. Active destination uses M3 indicator pill.
**Cards:** Single-column, full-width. Scrollable list. 16px horizontal padding.
---
### 3.3 Agent Card Component (Core Unit)
```
┌─────────────────────────────────────────────────────┐
│ ● ACTIVE [otto] [→ Jump] │
│ ─────────────────────────────────────────────────── │
│ 🤖 Otto │
│ Orchestrator Agent │
│ │
│ TASK: Reviewing PR #42 — extrudex-ui │
│ ████████████░░░░░░ 62% — 04m 12s elapsed │
│ │
│ SESSION: agent:otto:telegram:direct:8787... │
│ CHANNEL: Telegram [●] Last msg: 2m ago │
└─────────────────────────────────────────────────────┘
```
**Card anatomy:**
- **Status Badge** (top-left): Color pill — Active / Idle / Thinking / Error
- **Agent Tag** (top-center): Short ID chip
- **Quick-Jump CTA** (top-right): `→ Jump` icon button — M3 `FilledTonalIconButton`
- **Avatar + Name + Role** (mid-section): M3 List tile pattern
- **Active Task Row**: Task name + linear progress indicator
- **Session Footer**: Session key truncated + channel + last activity timestamp
**Card states:**
- **Default** — surface container background, subtle border
- **Active** — cyan left-border accent (4px), slightly elevated `+2dp`
- **Error** — red border, pulsing status dot
- **Thinking** — purple status + animated shimmer on task row
- **Idle** — muted, desaturated, lower elevation
- **Hover/Focus** — M3 state layer overlay (8% primary)
---
### 3.4 Quick-Jump Mechanism
On tap of `→ Jump` button on any agent card:
1. **Modal Bottom Sheet** (mobile) — M3 `ModalBottomSheet` slides up with:
- Agent name + status
- Last 3 messages (truncated)
- [Open Full Session] FAB
- [View Logs] secondary action
2. **Side Drawer Panel** (kiosk/desktop) — M3 `NavigationDrawer` (modal variant) slides in from right (480px wide) with:
- Agent name + session context
- Live log tail (last 20 lines, auto-scroll)
- [Open Full Session] primary button
- [Pin to Dashboard] secondary action
**Keyboard / touch shortcut:** Long-press card = opens Session Log directly (bypasses Quick-Jump drawer).
---
### 3.5 Global Navigation Structure
| Destination | Icon | Badge |
|-------------|------|-------|
| Command Hub | ⚡ Flash | None |
| Projects | 📋 Clipboard | Active count |
| Sessions | 🗂 Stack | Unread count |
| Logs | 📊 Chart | Error count |
| Settings | ⚙ Gear | — |
**Rail behavior:** Collapsed by default on kiosk. Tap hamburger `☰` to expand with labels.
**Bottom nav (mobile):** Always shows labels. Max 5 destinations. Overflow → menu.
---
## 4. UX Rationale
### Why Navigation Rail (Desktop) + Bottom Nav (Mobile)?
M3's adaptive navigation pattern — `NavigationRail` is the correct M3 component for persistent navigation on expanded screens. It keeps the full content width for the fleet grid. On mobile, `NavigationBar` (bottom) is ergonomically correct for thumb reach. The two layouts share identical navigation destinations but adapt the component.
### Why Card Grid, Not Table?
- Cards are **touch-friendly** (minimum 56px tap area per cell)
- Cards support **rich visual status** — tables flatten the hierarchy
- Cards allow **progressive disclosure** (collapsed vs expanded state)
- Tables are for data comparison; cards are for status triage
### Why Quick-Jump Drawer, Not Page Navigation?
Operators often want to **peek** at an agent without losing the fleet overview. A drawer preserves context — the fleet grid remains visible behind it. Full navigation to the agent session view is a secondary action requiring intentional tap.
### Why Left-Border Accent on Active Cards?
It's a scannable, pre-attentive visual cue. Eyes detect vertical color stripes faster than color fills. Lifted from terminal/IDE design patterns (like VS Code activity indicators). Consistent with Tactical Dark Mode aesthetics.
### Information Hierarchy on Card
Status → Identity → Current Task → Session Context
This ordering maps to the triage mental model: "Is it ok? Who is it? What's it doing? Where is it?"
---
## 5. Visual Direction
### 5.1 Color Palette (M3 Dynamic Color — Custom Dark Scheme)
| Role | Token | Hex | Usage |
|------|-------|-----|-------|
| Background | `md.sys.color.background` | `#0D0F12` | App background |
| Surface | `md.sys.color.surface` | `#13161A` | Card background |
| Surface Container | `md.sys.color.surface-container` | `#1C2027` | Elevated cards |
| Surface Container High | `md.sys.color.surface-container-high` | `#252B33` | Header, Nav Rail |
| On Surface | `md.sys.color.on-surface` | `#E2E8F0` | Primary text |
| On Surface Variant | `md.sys.color.on-surface-variant` | `#8A9BB0` | Secondary text |
| Outline | `md.sys.color.outline` | `#2D3748` | Card borders |
| Primary | `md.sys.color.primary` | `#38BDF8` | Active state, CTAs (Electric Blue) |
| On Primary | `md.sys.color.on-primary` | `#0C1825` | Text on primary |
| Secondary | `md.sys.color.secondary` | `#64748B` | Nav inactive |
| Tertiary | `md.sys.color.tertiary` | `#A78BFA` | Thinking state (Amethyst) |
**Status Colors (Semantic — outside M3 tonal system):**
| Status | Color | Hex | Dot Animation |
|--------|-------|-----|---------------|
| Active | Electric Blue | `#38BDF8` | Steady pulse (2s) |
| Idle | Muted Teal | `#2DD4BF` | Static |
| Thinking | Amethyst | `#A78BFA` | Slow pulse (3s) |
| Error | Vivid Red | `#F87171` | Fast pulse (0.8s) |
### 5.2 Typography
M3 Type Scale using **Inter** (primary) with **Roboto Mono** for session IDs and log output.
| Role | M3 Token | Size | Weight | Usage |
|------|----------|------|--------|-------|
| Display Small | `displaySmall` | 36sp | 400 | Screen title |
| Headline Medium | `headlineMedium` | 28sp | 400 | Agent name |
| Title Large | `titleLarge` | 22sp | 500 | Section headers |
| Title Medium | `titleMedium` | 16sp | 500 | Card title |
| Body Medium | `bodyMedium` | 14sp | 400 | Task description |
| Label Large | `labelLarge` | 14sp | 500 | Buttons, badges |
| Label Small | `labelSmall` | 11sp | 500 | Status chips, timestamps |
| Mono Body | — | 13sp | 400 | Session IDs, log text |
### 5.3 Elevation & Surfaces
Using M3's tonal surface elevation (not drop shadows):
| Level | Overlay | Usage |
|-------|---------|-------|
| Level 0 | 0% | App background |
| Level 1 | 5% | Cards (default) |
| Level 2 | 8% | Cards (active/hover) |
| Level 3 | 11% | Navigation Rail |
| Level 4 | 12% | Header / App Bar |
| Level 5 | 14% | Modal overlays |
### 5.4 Spacing & Grid
- Base unit: **8px**
- Card padding: **20px** (2.5 units)
- Card gap (grid): **16px** (2 units)
- Section padding: **24px** (3 units)
- Nav Rail width collapsed: **72px**
- Nav Rail width expanded: **256px**
- Card min-width: **320px**, preferred: **360px**
- Card border-radius: **16px** (M3 ExtraLarge shape token)
- Status dot size: **10px**
### 5.5 Iconography
M3 Material Symbols (Outlined weight, `FILL=0`, `wght=400`, `GRAD=0`, `opsz=24`).
Key icons:
- Agent status dot: Custom SVG animated circle
- Quick-Jump: `arrow_forward` or `open_in_new`
- Active: `bolt`
- Thinking: `psychology` or `autorenew`
- Error: `error_outline`
- Idle: `pause_circle`
- Settings: `settings`
- Notifications: `notifications`
---
## 6. Responsiveness
### Breakpoints (M3 Adaptive Layout)
| Breakpoint | Width | Layout | Navigation | Columns |
|------------|-------|---------|------------|---------|
| Compact | 0599px | Mobile | Bottom Nav | 1 |
| Medium | 6001023px | Tablet | Nav Rail (collapsed) | 2 |
| Expanded | ≥ 1024px | Desktop/Kiosk | Nav Rail (expandable) | 3+ |
### Adaptive Behaviors
**Compact (Phone):**
- Cards: Full-width, stacked vertically
- Quick-Jump: Modal Bottom Sheet
- Header: Small top app bar (M3 `SmallTopAppBar`)
- Status filter: Horizontal scroll chip group
**Medium (Tablet):**
- Cards: 2-column grid
- Nav Rail: Collapsed (icons only)
- Quick-Jump: Side panel (half-width drawer)
**Expanded (Kiosk/Desktop):**
- Cards: 3+ column auto-fill grid
- Nav Rail: Expandable with labels
- Quick-Jump: Side drawer (480px)
- Header: Medium top app bar with subtitle
### Touch Targets
- All interactive elements: minimum **48×48dp** (M3 spec)
- Card tap area: full card (not just button)
- Quick-Jump button: **56×56dp** (touch-optimized)
- Bottom nav items: minimum **48dp** height
---
## 7. Developer Handoff Notes
### 7.1 Technology Stack
- **Frontend:** Angular 17+ with Angular Material (M3 theming)
- **Realtime:** SignalR WebSocket hub — `AgentStatusHub`
- **State Management:** NgRx (recommended) or Angular Signals
- **Styling:** Angular Material theming + custom CSS custom properties for status colors
### 7.2 M3 Theme Configuration (Angular Material)
```scss
// _theme.scss
@use '@angular/material' as mat;
$dark-theme: mat.define-theme((
color: (
theme-type: dark,
primary: mat.$cyan-palette, // #38BDF8 family
tertiary: mat.$violet-palette, // #A78BFA family
),
typography: (
brand-family: 'Inter, sans-serif',
plain-family: 'Inter, sans-serif',
bold-weight: 600,
medium-weight: 500,
regular-weight: 400,
),
density: (
scale: 0,
),
));
// Custom CSS vars for status colors
:root {
--status-active: #38BDF8;
--status-idle: #2DD4BF;
--status-thinking: #A78BFA;
--status-error: #F87171;
--status-active-bg: rgba(56, 189, 248, 0.12);
--status-error-bg: rgba(248, 113, 113, 0.12);
}
```
### 7.3 Agent Card Component Interface
```typescript
interface AgentCardData {
id: string; // e.g., "otto"
displayName: string; // e.g., "Otto"
role: string; // e.g., "Orchestrator Agent"
status: AgentStatus; // 'active' | 'idle' | 'thinking' | 'error'
currentTask?: string; // e.g., "Reviewing PR #42"
taskProgress?: number; // 0100
taskElapsed?: string; // e.g., "04m 12s"
sessionKey: string; // full session key
channel: string; // e.g., "telegram"
lastActivity: Date;
errorMessage?: string; // populated on error state
}
type AgentStatus = 'active' | 'idle' | 'thinking' | 'error';
```
### 7.4 SignalR Integration
```typescript
// agent-status.service.ts
export class AgentStatusService {
private hubConnection: HubConnection;
connect() {
this.hubConnection = new HubConnectionBuilder()
.withUrl('/hubs/agent-status')
.withAutomaticReconnect()
.build();
this.hubConnection.on('AgentStatusChanged', (update: AgentStatusUpdate) => {
this.store.dispatch(AgentActions.statusUpdated({ update }));
});
this.hubConnection.on('AgentTaskProgress', (progress: TaskProgressUpdate) => {
this.store.dispatch(AgentActions.taskProgressUpdated({ progress }));
});
}
}
```
### 7.5 Animation Specs
```css
/* Status dot pulse animations */
@keyframes pulse-active {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.15); }
}
@keyframes pulse-error {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes pulse-thinking {
0%, 100% { opacity: 0.8; }
50% { opacity: 0.4; }
}
.status-dot--active { animation: pulse-active 2s ease-in-out infinite; }
.status-dot--error { animation: pulse-error 0.8s ease-in-out infinite; }
.status-dot--thinking { animation: pulse-thinking 3s ease-in-out infinite; }
.status-dot--idle { /* static */ }
/* Quick-jump card border accent */
.agent-card--active { border-left: 4px solid var(--status-active); }
.agent-card--error { border-left: 4px solid var(--status-error); }
.agent-card--thinking { border-left: 4px solid var(--status-thinking); }
.agent-card--idle { border-left: 2px solid var(--md-sys-color-outline); }
```
### 7.6 Angular Component Structure
```
src/app/
command-hub/
command-hub.component.ts ← Shell, grid layout
command-hub.component.html
command-hub.component.scss
components/
agent-card/
agent-card.component.ts ← Core card unit
agent-card.component.html
agent-card.component.scss
fleet-status-filter/
fleet-status-filter.component.ts
quick-jump-drawer/
quick-jump-drawer.component.ts ← Side drawer (desktop)
quick-jump-sheet/
quick-jump-sheet.component.ts ← Bottom sheet (mobile)
services/
agent-status.service.ts
store/
agent.actions.ts
agent.reducer.ts
agent.selectors.ts
agent.effects.ts
```
### 7.7 Accessibility
- All status indicators must have text alternatives (`aria-label="Status: Active"`)
- Cards are `role="article"` with `aria-labelledby` pointing to agent name
- Color is never the ONLY differentiator — icons accompany all status colors
- Focus ring: M3 focus indicator (3dp ring, `md.sys.color.secondary`)
- Reduced motion: all pulse animations respect `prefers-reduced-motion: reduce`
- Live region for status changes: `aria-live="polite"` on status badge
### 7.8 Performance Notes
- Agent cards use `@defer` (Angular 17) for lazy rendering below the fold
- SignalR updates trigger only targeted card updates via NgRx selectors (no full re-render)
- Status dot animations use `will-change: transform, opacity` for GPU compositing
- Limit fleet grid to visible viewport; virtual scroll for > 20 agents (`@angular/cdk/scrolling`)
---
## Appendix A: Status Color Reference Card
```
● #38BDF8 ACTIVE — Agent currently processing a task
● #2DD4BF IDLE — Agent online, no active task
● #A78BFA THINKING — Agent processing/reasoning (LLM call in flight)
● #F87171 ERROR — Agent encountered an unhandled error
○ #64748B OFFLINE — Agent unreachable / session terminated
```
## Appendix B: Quick-Jump UX Flow
```
User taps [→ Jump] on Agent Card
├─ Desktop/Kiosk ──► Right side drawer slides in (480px)
│ Shows: live log tail, last msgs, [Open Full Session]
└─ Mobile ──────────► Modal Bottom Sheet rises
Shows: agent name, 3 recent msgs, [Open Full Session]
User taps [Open Full Session]
└─ Router navigate to /sessions/:agentId
Full page Agent Session View
```
---
*Spec v1.0 — Ready for implementation. Questions → Sketch (UI/UX Agent)*

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

17
frontend/.editorconfig Normal file
View File

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

44
frontend/.gitignore vendored Normal file
View File

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

12
frontend/.prettierrc Normal file
View File

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

4
frontend/.vscode/extensions.json vendored Normal file
View File

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

20
frontend/.vscode/launch.json vendored Normal file
View File

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

9
frontend/.vscode/mcp.json vendored Normal file
View File

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

42
frontend/.vscode/tasks.json vendored Normal file
View File

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

59
frontend/README.md Normal file
View File

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

97
frontend/angular.json Normal file
View File

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

8034
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

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

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

344
frontend/src/app/app.html Normal file
View File

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

View File

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

View File

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

16
frontend/src/app/app.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
<div class="layout-shell">
<!-- Desktop/Kiosk: Nav Rail on the left -->
<app-nav-rail class="layout-shell__nav-rail" />
<div class="layout-shell__main">
<!-- Header bar at top of content area -->
<app-header-bar class="layout-shell__header" />
<!-- Scrollable content area -->
<main class="layout-shell__content">
<router-outlet />
</main>
</div>
<!-- Mobile: Bottom Navigation Bar -->
<app-bottom-nav class="layout-shell__bottom-nav" />
</div>

View File

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

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component } 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';
/**
* 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],
templateUrl: './layout-shell.component.html',
styleUrl: './layout-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LayoutShellComponent {}

View File

@@ -0,0 +1,44 @@
<aside
class="nav-rail"
[class.nav-rail--expanded]="expanded()"
[attr.aria-label]="'Navigation'"
>
<!-- Header with OpenClaw brand -->
<div class="nav-rail__header">
<button
class="nav-rail__toggle"
(click)="toggleExpand()"
[attr.aria-label]="expanded() ? 'Collapse navigation' : 'Expand navigation'"
[attr.aria-expanded]="expanded()"
>
<mat-icon>menu</mat-icon>
</button>
@if (expanded()) {
<span class="nav-rail__brand">OpenClaw</span>
}
</div>
<!-- Navigation destinations -->
<nav class="nav-rail__nav">
@for (dest of destinations; track dest.route) {
<a
[routerLink]="dest.route"
routerLinkActive="nav-rail__item--active"
[attr.aria-label]="dest.label"
class="nav-rail__item"
>
<mat-icon
[matBadge]="dest.badge ?? 0"
[matBadgeHidden]="!dest.badge"
matBadgePosition="above after"
matBadgeSize="small"
>
{{ dest.icon }}
</mat-icon>
@if (expanded()) {
<span class="nav-rail__label">{{ dest.label }}</span>
}
</a>
}
</nav>
</aside>

View File

@@ -0,0 +1,112 @@
// ============================================================================
// Nav Rail — Desktop/Kiosk Navigation
// Per spec Section 3.1: 72px collapsed / 256px expanded
// Section 5.4: Spacing & Grid
// ============================================================================
.nav-rail {
display: flex;
flex-direction: column;
width: var(--cc-nav-rail-collapsed-width);
min-height: 100vh;
background-color: var(--cc-surface-container-high);
border-right: 1px solid var(--cc-outline);
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
z-index: 10;
&--expanded {
width: var(--cc-nav-rail-expanded-width);
}
}
.nav-rail__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 12px;
min-height: 64px;
border-bottom: 1px solid var(--cc-outline);
}
.nav-rail__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
min-width: 48px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--cc-on-surface);
cursor: pointer;
transition: background-color 150ms ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
}
&:focus-visible {
outline: 3px solid var(--status-active);
outline-offset: 2px;
}
}
.nav-rail__brand {
font-size: 18px;
font-weight: 600;
color: var(--status-active);
white-space: nowrap;
letter-spacing: 0.02em;
}
.nav-rail__nav {
flex: 1;
padding-top: 8px;
// Override Angular Material list item styles for compact nav rail items
--mat-list-list-item-one-line-vertical-gap: 4px;
}
.nav-rail__item {
display: flex;
align-items: center;
gap: 16px;
min-height: 56px;
padding: 0 12px;
border-radius: 28px;
margin: 2px 12px;
color: var(--cc-on-surface-variant);
text-decoration: none;
transition: background-color 150ms ease, color 150ms ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
color: var(--cc-on-surface);
}
&--active {
background-color: var(--status-active-bg);
color: var(--status-active);
.nav-rail__label {
font-weight: 500;
}
}
}
.nav-rail__label {
font-size: 14px;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Responsive: Hide nav rail on mobile (bottom nav takes over)
@media (max-width: 599px) {
.nav-rail {
display: none;
}
}

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, signal, HostListener } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatBadgeModule } from '@angular/material/badge';
import { NAV_DESTINATIONS } from '../../models/nav.model';
@Component({
selector: 'app-nav-rail',
standalone: true,
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
templateUrl: './nav-rail.component.html',
styleUrl: './nav-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavRailComponent {
protected readonly destinations = NAV_DESTINATIONS;
protected readonly expanded = signal(false);
@HostListener('mouseenter')
onHoverIn(): void {
this.expanded.set(true);
}
@HostListener('mouseleave')
onHoverOut(): void {
this.expanded.set(false);
}
toggleExpand(): void {
this.expanded.update(v => !v);
}
}

View File

@@ -0,0 +1,54 @@
// ============================================================================
// Agent Status Types
// Per spec Section 7.3: Agent Card Component Interface
// ============================================================================
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' | 'offline';
export interface AgentCardData {
/** Short agent ID, e.g., "otto" */
id: string;
/** Display name, e.g., "Otto" */
displayName: string;
/** Role description, e.g., "Orchestrator Agent" */
role: string;
/** Current agent status */
status: AgentStatus;
/** Current task description, e.g., "Reviewing PR #42" */
currentTask?: string;
/** Task progress percentage 0100 */
taskProgress?: number;
/** Elapsed time string, e.g., "04m 12s" */
taskElapsed?: string;
/** Full session key, e.g., "agent:otto:telegram:direct:8787..." */
sessionKey: string;
/** Communication channel, e.g., "telegram" */
channel: string;
/** Timestamp of last activity */
lastActivity: Date;
/** Error message (populated only on error status) */
errorMessage?: string;
}
export interface AgentStatusUpdate {
agentId: string;
status: AgentStatus;
timestamp: Date;
}
export interface TaskProgressUpdate {
agentId: string;
taskName?: string;
progress: number;
elapsed?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './agent.model';
export * from './nav.model';

View File

@@ -0,0 +1,19 @@
// ============================================================================
// Navigation Model
// Per spec Section 3.5: Global Navigation Structure
// ============================================================================
export interface NavDestination {
label: string;
icon: string;
route: string;
badge?: number;
}
export const NAV_DESTINATIONS: NavDestination[] = [
{ label: 'Command Hub', icon: 'bolt', route: '/hub' },
{ label: 'Projects', icon: 'assignment', route: '/projects' },
{ label: 'Sessions', icon: 'folder_open', route: '/sessions' },
{ label: 'Logs', icon: 'bar_chart', route: '/logs' },
{ label: 'Settings', icon: 'settings', route: '/settings' },
];

View File

@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-hub-page',
standalone: true,
imports: [],
template: `
<div class="hub-page">
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
</div>
`,
styles: [`
.hub-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.hub-page__placeholder {
color: var(--cc-on-surface-variant);
font-size: 16px;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HubPageComponent {}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-logs-page',
standalone: true,
imports: [],
template: `<p style="color: var(--cc-on-surface-variant)">Logs page — coming soon</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LogsPageComponent {}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-projects-page',
standalone: true,
imports: [],
template: `<p style="color: var(--cc-on-surface-variant)">Projects page — coming soon</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProjectsPageComponent {}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-sessions-page',
standalone: true,
imports: [],
template: `<p style="color: var(--cc-on-surface-variant)">Sessions page — coming soon</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SessionsPageComponent {}

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-settings-page',
standalone: true,
imports: [],
template: `<p style="color: var(--cc-on-surface-variant)">Settings page — coming soon</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SettingsPageComponent {}

View File

@@ -0,0 +1,47 @@
import { Injectable, signal } from '@angular/core';
import { AgentCardData, AgentStatus, AgentStatusUpdate, TaskProgressUpdate } from '../models/agent.model';
/**
* Agent Status Service — stub for future SignalR integration.
* Per spec Section 7.4: Connects to /hubs/agent-status for real-time updates.
*
* TODO: Implement SignalR hub connection when backend is ready.
* TODO: Wire up NgRx store or signals for reactive state management.
*/
@Injectable({ providedIn: 'root' })
export class AgentStatusService {
/** Stub: list of agents (will come from SignalR) */
private readonly _agents = signal<AgentCardData[]>([]);
readonly agents = this._agents.asReadonly();
/** Stub: update an agent's status */
updateStatus(update: AgentStatusUpdate): void {
this._agents.update(agents =>
agents.map(agent =>
agent.id === update.agentId
? { ...agent, status: update.status }
: agent
)
);
}
/** Stub: update an agent's task progress */
updateTaskProgress(progress: TaskProgressUpdate): void {
this._agents.update(agents =>
agents.map(agent =>
agent.id === progress.agentId
? {
...agent,
taskProgress: progress.progress,
...(progress.taskName ? { currentTask: progress.taskName } : {}),
...(progress.elapsed ? { taskElapsed: progress.elapsed } : {}),
}
: agent
)
);
}
// TODO: connect() — Initialize SignalR connection
// TODO: disconnect() — Clean up SignalR connection
}

25
frontend/src/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>OpenClaw Control Center</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Google Fonts: Inter (UI) + Roboto Mono (logs/session IDs) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<!-- Material Symbols (Outlined) per spec Section 5.5 -->
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0"
rel="stylesheet"
/>
</head>
<body>
<app-root></app-root>
</body>
</html>

6
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

212
frontend/src/styles.scss Normal file
View File

@@ -0,0 +1,212 @@
// ============================================================================
// OpenClaw Control Center — M3 Tactical Dark Theme
// ============================================================================
// Material Design 3 theming with custom dark palette per design spec.
// Section 5.1: Color Palette, Section 5.2: Typography
// ============================================================================
@use '@angular/material' as mat;
// ---------------------------------------------------------------------------
// M3 Theme Definition
// ---------------------------------------------------------------------------
// Using mat.define-theme() with custom color tokens to match the tactical
// dark palette. Angular Material 19+ uses the new theming API.
// ---------------------------------------------------------------------------
$dark-theme: mat.define-theme((
color: (
theme-type: dark,
primary: mat.$cyan-palette,
tertiary: mat.$violet-palette,
),
typography: (
brand-family: 'Inter, Roboto, sans-serif',
plain-family: 'Inter, Roboto, sans-serif',
bold-weight: 600,
medium-weight: 500,
regular-weight: 400,
),
density: (
scale: 0,
),
));
// ---------------------------------------------------------------------------
// Apply theme to :root
// ---------------------------------------------------------------------------
html {
height: 100%;
@include mat.theme($dark-theme);
color-scheme: dark;
}
// ---------------------------------------------------------------------------
// Custom CSS Custom Properties — Status Colors
// ---------------------------------------------------------------------------
// Per spec Section 5.1 "Status Colors (Semantic — outside M3 tonal system)"
// These are NOT part of the M3 tonal palette; they are semantic overrides.
// ---------------------------------------------------------------------------
:root {
// --- Status colors ---
--status-active: #38BDF8;
--status-idle: #2DD4BF;
--status-thinking: #A78BFA;
--status-error: #F87171;
--status-offline: #64748B;
// --- Status background tints (12% opacity) ---
--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);
// --- Surface overrides (tactical dark palette) ---
--cc-background: #0D0F12;
--cc-surface: #13161A;
--cc-surface-container: #1C2027;
--cc-surface-container-high: #252B33;
--cc-on-surface: #E2E8F0;
--cc-on-surface-variant: #8A9BB0;
--cc-outline: #2D3748;
// --- Mono font stack ---
--cc-font-mono: 'Roboto Mono', 'Cascadia Code', 'Fira Code', monospace;
// --- Layout constants ---
--cc-nav-rail-collapsed-width: 72px;
--cc-nav-rail-expanded-width: 256px;
--cc-header-height: 64px;
--cc-bottom-nav-height: 80px;
--cc-card-border-radius: 16px;
--cc-card-min-width: 320px;
--cc-card-gap: 16px;
--cc-card-padding: 20px;
--cc-section-padding: 24px;
--cc-spacing-unit: 8px;
}
// ---------------------------------------------------------------------------
// Global Body Styles
// ---------------------------------------------------------------------------
body {
background-color: var(--cc-background);
color: var(--cc-on-surface);
font-family: 'Inter', 'Roboto', sans-serif;
margin: 0;
height: 100%;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// ---------------------------------------------------------------------------
// M3 Surface Overrides
// ---------------------------------------------------------------------------
// Override M3 surface tokens to match our tactical dark palette
// ---------------------------------------------------------------------------
:root {
// Override M3 system color tokens to match custom palette
--mat-sys-surface: var(--cc-surface);
--mat-sys-surface-container: var(--cc-surface-container);
--mat-sys-surface-container-high: var(--cc-surface-container-high);
--mat-sys-on-surface: var(--cc-on-surface);
--mat-sys-on-surface-variant: var(--cc-on-surface-variant);
--mat-sys-outline: var(--cc-outline);
--mat-sys-background: var(--cc-background);
}
// ---------------------------------------------------------------------------
// Typography Helpers
// ---------------------------------------------------------------------------
.text-mono {
font-family: var(--cc-font-mono);
font-size: 13px;
font-weight: 400;
letter-spacing: 0.02em;
}
// ---------------------------------------------------------------------------
// Status Dot Pulse Animations
// ---------------------------------------------------------------------------
// Per spec Section 7.5: Animation Specs
// ---------------------------------------------------------------------------
@keyframes pulse-active {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.15); }
}
@keyframes pulse-error {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes pulse-thinking {
0%, 100% { opacity: 0.8; }
50% { opacity: 0.4; }
}
// ---------------------------------------------------------------------------
// Utility Classes
// ---------------------------------------------------------------------------
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
&--active {
background-color: var(--status-active);
animation: pulse-active 2s ease-in-out infinite;
}
&--idle {
background-color: var(--status-idle);
}
&--thinking {
background-color: var(--status-thinking);
animation: pulse-thinking 3s ease-in-out infinite;
}
&--error {
background-color: var(--status-error);
animation: pulse-error 0.8s ease-in-out infinite;
}
&--offline {
background-color: var(--status-offline);
}
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.status-dot--active,
.status-dot--error,
.status-dot--thinking {
animation: none;
}
}
// ---------------------------------------------------------------------------
// Scrollbar Styling (Tactical Dark)
// ---------------------------------------------------------------------------
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--cc-surface);
}
::-webkit-scrollbar-thumb {
background: var(--cc-outline);
border-radius: 3px;
&:hover {
background: var(--cc-on-surface-variant);
}
}

View File

@@ -0,0 +1,15 @@
/* 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
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
/* 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"
}
]
}

View File

@@ -0,0 +1,15 @@
/* 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/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}