CUB-20: Develop agent card component with dynamic status/progress
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m4s

This commit is contained in:
2026-04-28 08:18:27 -04:00
parent 8331468b44
commit a946670157
8 changed files with 538 additions and 68 deletions

View File

@@ -4,25 +4,118 @@
// Mobile (<1024px): single-column stack
// ============================================================================
@use 'tokens' as tokens;
.hub-page {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
padding: var(--cc-section-padding, 16px);
gap: tokens.$cc-card-gap;
padding: tokens.$cc-section-padding;
min-height: 400px;
overflow-x: hidden;
}
.hub-page__placeholder {
color: var(--cc-on-surface-variant);
font-size: 16px;
text-align: center;
padding: 24px 0;
}
// Desktop / kiosk breakpoint — 2-column grid
@media (min-width: 1024px) {
@media (min-width: tokens.$cc-breakpoint-desktop) {
.hub-page {
grid-template-columns: repeat(2, 1fr);
}
}
// ── Loading state ──
.hub-page__loading {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: var(--cc-on-surface-variant);
}
.hub-page__loading-icon {
font-size: 40px;
width: 40px;
height: 40px;
color: var(--status-active);
animation: spin 1.5s linear infinite;
}
.hub-page__loading-text {
font-size: 16px;
color: var(--cc-on-surface-variant);
margin: 0;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// ── Empty state ──
.hub-page__empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 24px;
text-align: center;
}
.hub-page__empty-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: var(--status-offline);
}
.hub-page__empty-text {
font-size: 18px;
font-weight: 500;
color: var(--cc-on-surface);
margin: 0;
}
.hub-page__empty-hint {
font-size: 14px;
color: var(--cc-on-surface-variant);
margin: 0;
}
// ── Error state ──
.hub-page__error {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
border: 1px solid var(--status-error);
border-radius: tokens.$cc-card-border-radius;
background-color: var(--status-error-bg);
}
.hub-page__error-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--status-error);
}
.hub-page__error-text {
font-size: 14px;
color: var(--status-error);
margin: 0;
text-align: center;
}
// ── Accessibility: reduced motion ──
@media (prefers-reduced-motion: reduce) {
.hub-page__loading-icon {
animation: none;
}
}

View File

@@ -1,15 +1,154 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
Signal,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { AgentCardComponent } from '../../command-hub/components/agent-card/agent-card.component';
import { AgentCardData } from '../../models/agent.model';
import { AgentStatusService } from '../../services/agent-status.service';
// ============================================================================
// Hub Page — Fleet Status Grid
// Per spec Section 7.3: Renders AgentCard components in a responsive grid.
// Handles loading, empty, error, and success states.
// Uses Angular signals from AgentStatusService for reactive updates.
// ============================================================================
@Component({
selector: 'app-hub-page',
standalone: true,
imports: [],
imports: [CommonModule, MatIconModule, AgentCardComponent],
template: `
<div class="hub-page">
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
<!-- Loading state -->
<div class="hub-page__loading" *ngIf="loading()">
<mat-icon class="hub-page__loading-icon" aria-hidden="true">hourglass_empty</mat-icon>
<p class="hub-page__loading-text">Loading agents…</p>
</div>
<!-- Empty state -->
<div class="hub-page__empty" *ngIf="!loading() && agents().length === 0">
<mat-icon class="hub-page__empty-icon" aria-hidden="true">smart_toy</mat-icon>
<p class="hub-page__empty-text">No agents connected yet.</p>
<p class="hub-page__empty-hint">Agents will appear here once they come online.</p>
</div>
<!-- Agent card grid -->
<app-agent-card
*ngFor="let agent of agents(); trackBy: trackByAgentId"
[status]="agent.status"
[task]="agent.currentTask ?? ''"
[progress]="agent.taskProgress ?? 0"
[sessionKey]="agent.sessionKey"
[channel]="agent.channel"
[lastActivity]="agent.lastActivity"
[agentId]="agent.id"
[displayName]="agent.displayName"
[role]="agent.role"
[errorMessage]="agent.errorMessage ?? ''"
[taskElapsed]="agent.taskElapsed ?? ''"
/>
<!-- Error state -->
<div class="hub-page__error" *ngIf="error()" [attr.aria-label]="'Error: ' + error()">
<mat-icon class="hub-page__error-icon" aria-hidden="true">error_outline</mat-icon>
<p class="hub-page__error-text">{{ error() }}</p>
</div>
</div>
`,
styleUrl: './hub-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HubPageComponent {}
export class HubPageComponent implements OnInit {
/** Agent list signal from the status service */
readonly agents: Signal<AgentCardData[]>;
/** Whether the initial load is in progress */
readonly loading = computed(() => this._initialLoad);
/** Error message signal */
readonly error = computed(() => this._errorMsg);
private _initialLoad = true;
private _errorMsg: string | null = null;
private readonly _agentService = inject(AgentStatusService);
constructor() {
this.agents = this._agentService.agents;
}
ngOnInit(): void {
// Seed mock data for development until SignalR is wired up
this._seedMockData();
}
/** TrackBy function for *ngFor performance */
trackByAgentId(index: number, agent: AgentCardData): string {
return agent.id;
}
// --- Mock data seeding (remove when SignalR is integrated) ---
private _seedMockData(): void {
const now = new Date();
const mockAgents: AgentCardData[] = [
{
id: 'otto',
displayName: 'Otto',
role: 'Orchestrator Agent',
status: 'active',
currentTask: 'Reviewing PR #42',
taskProgress: 72,
taskElapsed: '04m 12s',
sessionKey: 'agent:otto:slack:control-center:8787a',
channel: 'slack',
lastActivity: new Date(now.getTime() - 30_000),
},
{
id: 'rex',
displayName: 'Rex',
role: 'Frontend Agent',
status: 'thinking',
currentTask: 'Building agent card component',
taskProgress: 45,
taskElapsed: '02m 38s',
sessionKey: 'agent:rex:telegram:direct:b3c91',
channel: 'telegram',
lastActivity: new Date(now.getTime() - 5_000),
},
{
id: 'dex',
displayName: 'Dex',
role: 'Backend Agent',
status: 'idle',
currentTask: '',
taskProgress: 0,
sessionKey: 'agent:dex:slack:control-center:4f2a0',
channel: 'slack',
lastActivity: new Date(now.getTime() - 300_000),
},
{
id: 'hex',
displayName: 'Hex',
role: 'Database Agent',
status: 'error',
currentTask: 'Migration failed',
taskProgress: 0,
errorMessage: 'Connection timeout to PostgreSQL',
sessionKey: 'agent:hex:telegram:direct:9d1e7',
channel: 'telegram',
lastActivity: new Date(now.getTime() - 1_200_000),
},
];
this._agentService['_agents'].set(mockAgents);
this._initialLoad = false;
}
}