From 7eaf736fc4414dfc4a6d7207a8fcbd6af2970e9a Mon Sep 17 00:00:00 2001 From: "cubecraft-agents[bot]" <3458173+cubecraft-agents[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:36:55 +0000 Subject: [PATCH] CUB-23: implement AgentCard component with status indicators, progress bar, and M3 styling - AgentCard component with inputs: status, task, progress, sessionKey, channel, lastActivity - Status badge colors: Active (#38BDF8), Idle (#2DD4BF), Thinking (#A78BFA), Error (#F87171) - 4px left-border accent matching status color via CSS custom properties - Pulse animations: Active (2s), Error (0.8s), Thinking (3s) using global styles.scss keyframes - Task progress bar with percentage display - Quick-Jump button using M3 FilledTonalIconButton style with state layer overlay - Hover/focus states with M3 state layer overlay (8% primary) - Accessibility: aria-labels for status, role=article on cards, focus-visible outlines - Reduced-motion media query support - Updated HubPageComponent with demo data grid layout --- .../agent-card/agent-card.component.html | 71 +++++ .../agent-card/agent-card.component.scss | 277 ++++++++++++++++++ .../agent-card/agent-card.component.ts | 120 ++++++++ .../src/app/components/agent-card/index.ts | 1 + .../src/app/pages/hub/hub-page.component.ts | 84 +++++- 5 files changed, 542 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/components/agent-card/agent-card.component.html create mode 100644 frontend/src/app/components/agent-card/agent-card.component.scss create mode 100644 frontend/src/app/components/agent-card/agent-card.component.ts create mode 100644 frontend/src/app/components/agent-card/index.ts diff --git a/frontend/src/app/components/agent-card/agent-card.component.html b/frontend/src/app/components/agent-card/agent-card.component.html new file mode 100644 index 0000000..ebc18b0 --- /dev/null +++ b/frontend/src/app/components/agent-card/agent-card.component.html @@ -0,0 +1,71 @@ +
+ +
+ + +
+ +
+
+ + + {{ statusLabel() }} + +
+
+ @if (channel()) { + + + {{ channel() }} + + } + {{ lastActivityText() }} +
+
+ + + @if (task()) { +

{{ task() }}

+ } + + + @if (showProgress()) { +
+ +
+ {{ progress() }}% +
+
+ } + + + +
+
\ No newline at end of file diff --git a/frontend/src/app/components/agent-card/agent-card.component.scss b/frontend/src/app/components/agent-card/agent-card.component.scss new file mode 100644 index 0000000..ba53328 --- /dev/null +++ b/frontend/src/app/components/agent-card/agent-card.component.scss @@ -0,0 +1,277 @@ +// ============================================================================ +// Agent Card Styles — M3 Tactical Dark +// Per spec Section 7.3: Agent Card Component +// Section 7.5: Animation Specs +// ============================================================================ + +// --------------------------------------------------------------------------- +// Card Shell +// --------------------------------------------------------------------------- +.agent-card { + display: flex; + position: relative; + min-width: var(--cc-card-min-width, 320px); + border-radius: var(--cc-card-border-radius, 16px); + background-color: var(--cc-surface-container); + overflow: hidden; + transition: background-color 150ms ease, box-shadow 150ms ease; + + // M3 state layer overlay on hover (8% primary) + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: rgba(255, 255, 255, 0.08); + opacity: 0; + transition: opacity 150ms ease; + pointer-events: none; + } + + &:hover::after { + opacity: 1; + } + + // Focus-visible outline for keyboard navigation + &:focus-visible { + outline: 3px solid var(--status-active); + outline-offset: 2px; + } +} + +// --------------------------------------------------------------------------- +// Left-border accent (4px, matching status color) +// --------------------------------------------------------------------------- +.agent-card__accent { + flex-shrink: 0; + width: 4px; + background-color: var(--agent-status-color); + border-radius: 4px 0 0 4px; +} + +// --------------------------------------------------------------------------- +// Card Body +// --------------------------------------------------------------------------- +.agent-card__body { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + padding: var(--cc-card-padding, 20px); + min-width: 0; // Prevent flex blowout for long text +} + +// --------------------------------------------------------------------------- +// Header Row +// --------------------------------------------------------------------------- +.agent-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.agent-card__status-row { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-card__dot { + flex-shrink: 0; +} + +.agent-card__status-label { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.agent-card__meta { + display: flex; + align-items: center; + gap: 12px; + color: var(--cc-on-surface-variant); + font-size: 12px; +} + +.agent-card__channel { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.agent-card__channel-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + +.agent-card__last-activity { + opacity: 0.7; +} + +// --------------------------------------------------------------------------- +// Task Description +// --------------------------------------------------------------------------- +.agent-card__task { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--cc-on-surface); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +// --------------------------------------------------------------------------- +// Progress Bar +// --------------------------------------------------------------------------- +.agent-card__progress { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-card__progress-info { + display: flex; + justify-content: flex-end; + font-size: 12px; + color: var(--cc-on-surface-variant); +} + +// Override M3 progress bar track to use status color +.agent-card__progress .mat-mdc-progress-bar { + --mdc-linear-progress-active-indicator-color: var(--agent-status-color); + --mdc-linear-progress-track-color: var(--agent-status-bg); +} + +// --------------------------------------------------------------------------- +// Footer Row +// --------------------------------------------------------------------------- +.agent-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: auto; // Push footer to bottom +} + +.agent-card__session { + font-size: 11px; + color: var(--cc-on-surface-variant); + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +// --------------------------------------------------------------------------- +// Quick-Jump Button — M3 FilledTonalIconButton style +// Per spec: → Jump button using M3 FilledTonalIconButton +// --------------------------------------------------------------------------- +.agent-card__jump { + // M3 FilledTonal style: secondary container color + --mdc-icon-button-icon-color: var(--cc-on-surface); + background-color: var(--cc-surface-container-high); + border-radius: 50%; + width: 40px; + height: 40px; + flex-shrink: 0; + + // State layer overlay on hover (8% primary) + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.08); + opacity: 0; + transition: opacity 150ms ease; + pointer-events: none; + } + + &:hover::before { + opacity: 1; + } + + // Focus-visible ring + &:focus-visible { + outline: 3px solid var(--status-active); + outline-offset: 2px; + } + + // Active state + &:active { + transform: scale(0.95); + } + + .mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } +} + +// --------------------------------------------------------------------------- +// Status-specific card backgrounds (subtle tint) +// --------------------------------------------------------------------------- +.agent-card--active { + background-color: var(--status-active-bg); +} + +.agent-card--thinking { + background-color: var(--status-thinking-bg); +} + +.agent-card--error { + background-color: var(--status-error-bg); +} + +// Idle and offline use default surface-container + +// --------------------------------------------------------------------------- +// Touch-First Targets +// --------------------------------------------------------------------------- +// Minimum touch target: 48px (M3 standard) +.agent-card__jump { + min-width: 48px; + min-height: 48px; +} + +// --------------------------------------------------------------------------- +// Accessibility: Reduced Motion +// --------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .agent-card { + transition: none; + } + + .agent-card__jump { + transition: none; + + &:active { + transform: none; + } + } +} + +// --------------------------------------------------------------------------- +// Responsive: Mobile adjustments +// --------------------------------------------------------------------------- +@media (max-width: 599px) { + .agent-card { + min-width: unset; + } + + .agent-card__body { + padding: 16px; + gap: 8px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-card/agent-card.component.ts b/frontend/src/app/components/agent-card/agent-card.component.ts new file mode 100644 index 0000000..6ccc7be --- /dev/null +++ b/frontend/src/app/components/agent-card/agent-card.component.ts @@ -0,0 +1,120 @@ +// ============================================================================ +// Agent Card Component +// Per spec Section 7.3: Agent Card Component Interface +// Section 7.5: Animation Specs +// ============================================================================ +import { ChangeDetectionStrategy, Component, input, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { AgentStatus } from '../../models/agent.model'; + +/** + * AgentCard displays a single agent's status in the Command Hub grid. + * + * Inputs: + * - status: AgentStatus — current agent status + * - task: string — current task description + * - progress: number — task progress percentage (0–100) + * - sessionKey: string — full session key + * - channel: string — communication channel (e.g., "telegram") + * - lastActivity: Date — timestamp of last activity + * + * Accessibility: + * - role="article" on the card element + * - aria-labels for status badge and interactive elements + * - focus-visible outlines for keyboard navigation + */ +@Component({ + selector: 'app-agent-card', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + MatProgressBarModule, + MatTooltipModule, + ], + templateUrl: './agent-card.component.html', + styleUrl: './agent-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AgentCardComponent { + /** Current agent status */ + readonly status = input.required(); + + /** Current task description */ + readonly task = input(''); + + /** Task progress percentage (0–100) */ + readonly progress = input(0); + + /** Full session key */ + readonly sessionKey = input.required(); + + /** Communication channel (e.g., "telegram") */ + readonly channel = input(''); + + /** Timestamp of last activity */ + readonly lastActivity = input(new Date()); + + /** Map status to CSS custom property name for dynamic color binding */ + readonly statusColorVar = computed(() => { + const map: Record = { + active: 'var(--status-active)', + idle: 'var(--status-idle)', + thinking: 'var(--status-thinking)', + error: 'var(--status-error)', + offline: 'var(--status-offline)', + }; + return map[this.status()]; + }); + + /** Map status to background tint CSS variable */ + readonly statusBgVar = computed(() => { + const map: Record = { + active: 'var(--status-active-bg)', + idle: 'var(--status-idle-bg)', + thinking: 'var(--status-thinking-bg)', + error: 'var(--status-error-bg)', + offline: 'var(--cc-surface-container)', + }; + return map[this.status()]; + }); + + /** Human-readable status label */ + readonly statusLabel = computed(() => { + const map: Record = { + active: 'Active', + idle: 'Idle', + thinking: 'Thinking', + error: 'Error', + offline: 'Offline', + }; + return map[this.status()]; + }); + + /** CSS class for status dot animation */ + readonly statusDotClass = computed(() => `status-dot--${this.status()}`); + + /** Format last activity as relative time string */ + readonly lastActivityText = computed(() => { + const now = Date.now(); + const then = this.lastActivity().getTime(); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + return `${Math.floor(diffHr / 24)}d ago`; + }); + + /** Whether to show progress bar */ + readonly showProgress = computed(() => { + const s = this.status(); + return (s === 'active' || s === 'thinking') && this.progress() > 0; + }); +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-card/index.ts b/frontend/src/app/components/agent-card/index.ts new file mode 100644 index 0000000..f9c7df5 --- /dev/null +++ b/frontend/src/app/components/agent-card/index.ts @@ -0,0 +1 @@ +export { AgentCardComponent } from './agent-card.component'; \ No newline at end of file diff --git a/frontend/src/app/pages/hub/hub-page.component.ts b/frontend/src/app/pages/hub/hub-page.component.ts index 7819be4..51b9026 100644 --- a/frontend/src/app/pages/hub/hub-page.component.ts +++ b/frontend/src/app/pages/hub/hub-page.component.ts @@ -1,26 +1,88 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { AgentCardComponent } from '../../components/agent-card/agent-card.component'; +import { AgentCardData } from '../../models/agent.model'; @Component({ selector: 'app-hub-page', standalone: true, - imports: [], + imports: [AgentCardComponent], template: `
-

Command Hub — Fleet status grid will render here

+
+ @for (agent of agents(); track agent.id) { + + } +
`, styles: [` .hub-page { - display: flex; - align-items: center; - justify-content: center; - min-height: 400px; + padding: var(--cc-section-padding, 24px); } - .hub-page__placeholder { - color: var(--cc-on-surface-variant); - font-size: 16px; + + .hub-page__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--cc-card-min-width, 320px), 1fr)); + gap: var(--cc-card-gap, 16px); } `], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class HubPageComponent {} \ No newline at end of file +export class HubPageComponent { + /** Demo agents for development — will be replaced by SignalR data */ + protected readonly agents = signal([ + { + id: 'otto', + displayName: 'Otto', + role: 'Orchestrator', + status: 'active', + currentTask: 'Reviewing PR #42', + taskProgress: 65, + taskElapsed: '04m 12s', + sessionKey: 'agent:otto:telegram:direct:8787451565', + channel: 'telegram', + lastActivity: new Date(), + }, + { + id: 'rex', + displayName: 'Rex', + role: 'Frontend Specialist', + status: 'thinking', + currentTask: 'Building AgentCard component', + taskProgress: 30, + taskElapsed: '02m 45s', + sessionKey: 'agent:rex:subagent:0cdbf600', + channel: 'telegram', + lastActivity: new Date(Date.now() - 120000), + }, + { + id: 'dex', + displayName: 'Dex', + role: 'Backend Engineer', + status: 'idle', + sessionKey: 'agent:dex:slack:channel:C01234567', + channel: 'slack', + lastActivity: new Date(Date.now() - 300000), + }, + { + id: 'hex', + displayName: 'Hex', + role: 'Database Architect', + status: 'error', + currentTask: 'Migration failed — rollback', + taskProgress: 0, + taskElapsed: '00m 00s', + sessionKey: 'agent:hex:slack:channel:C01234568', + channel: 'slack', + lastActivity: new Date(Date.now() - 1800000), + errorMessage: 'Connection timeout', + }, + ]); +} \ No newline at end of file