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

@@ -2,7 +2,12 @@ import {
ChangeDetectionStrategy,
Component,
Input,
OnDestroy,
Signal,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
@@ -11,11 +16,17 @@ import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AgentStatus } from '../../../models/agent.model';
import {
STATUS_COLORS,
STATUS_LABELS,
CHANNEL_ICONS,
} from '../../../design/tokens';
// ============================================================================
// AgentCard Component
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
// and QuickJump Button into a card with leftborder status accent.
// Enhanced with data-status attribute, elapsed time, and design tokens.
// ============================================================================
@Component({
@@ -33,7 +44,7 @@ import { AgentStatus } from '../../../models/agent.model';
styleUrl: './agent-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentCardComponent {
export class AgentCardComponent implements OnDestroy {
// --- Six required inputs per spec ---
/** Agent status — drives badge color and leftborder accent */
@@ -68,7 +79,18 @@ export class AgentCardComponent {
/** Error message (shown only when status is 'error') */
@Input() errorMessage = '';
// --- Computed values ---
/** Elapsed time string, e.g. "04m 12s" */
@Input() taskElapsed = '';
// --- Internal state ---
/** Timer for refreshing relative-time label */
private _timer: ReturnType<typeof setInterval> | null = null;
/** Internal signal to trigger relative-time recomputation */
private readonly _tick = signal(0);
// --- Computed values using design tokens ---
/** Map status → CSS custom property for the leftborder accent */
readonly statusBorderColor = computed(() => {
@@ -82,46 +104,68 @@ export class AgentCardComponent {
return map[this.status] ?? 'var(--status-offline)';
});
/** Humanreadable status label */
readonly statusLabel = computed(() => {
const labels: Record<AgentStatus, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking…',
error: 'Error',
offline: 'Offline',
};
return labels[this.status] ?? this.status;
});
/** Humanreadable status label (from design tokens) */
readonly statusLabel = computed(() => STATUS_LABELS[this.status] ?? this.status);
/** CSS class suffix for the status badge dot */
readonly statusDotClass = computed(() => `status-dot--${this.status}`);
/** Material icon name for the channel */
readonly channelIcon = computed(() => {
const icons: Record<string, string> = {
telegram: 'telegram', // falls back to font icon if no SVG registered
slack: 'chat',
discord: 'forum',
whatsapp: 'chat',
webchat: 'language',
email: 'email',
};
return icons[this.channel] ?? 'chat';
/** Material icon name for the channel (from design tokens) */
readonly channelIcon = computed(() => CHANNEL_ICONS[this.channel] ?? 'chat');
/** Relative time string for lastActivity, refreshed every 30s */
readonly lastActivityLabel = computed(() => {
// Read tick to create dependency that forces recomputation
this._tick();
return this._relativeTime(this.lastActivity);
});
/** Relative time string for lastActivity */
readonly lastActivityLabel = computed(() => {
if (!this.lastActivity) return '';
/** Quickjump route derived from sessionKey */
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
/** Whether progress bar should show */
readonly showProgress = computed(() => this.progress > 0 && this.status !== 'error');
/** Whether error state is active */
readonly isError = computed(() => this.status === 'error');
/** Whether card is in an active-like state (active or thinking) */
readonly isActiveLike = computed(() => this.status === 'active' || this.status === 'thinking');
constructor() {
// Start the relative-time refresh timer
this._startTimer();
}
ngOnDestroy(): void {
this._stopTimer();
}
// --- Private helpers ---
private _relativeTime(date: Date | null | undefined): string {
if (!date) return '';
const now = Date.now();
const then = this.lastActivity.getTime();
const then = date.getTime();
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
});
}
/** Quickjump route derived from sessionKey */
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
private _startTimer(): void {
this._stopTimer();
this._timer = setInterval(() => {
// Increment tick to force lastActivityLabel recomputation
this._tick.update(v => v + 1);
}, 30_000);
}
private _stopTimer(): void {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
}
}