Compare commits

..

1 Commits

23 changed files with 456 additions and 1037 deletions

View File

@@ -0,0 +1,82 @@
<!-- ========================================================================== -->
<!-- AgentCard — per spec Section 7.3 -->
<!-- Integrates: Status Badge · Task Progress Bar · QuickJump Button -->
<!-- Leftborder accent matches status color. role="article" + arialabels. -->
<!-- ========================================================================== -->
<article
class="agent-card"
role="article"
[attr.aria-label]="displayName + ' ' + statusLabel()"
[style.border-left-color]="statusBorderColor()"
>
<!-- ── Header: status badge + agent info ── -->
<div class="agent-card__header">
<div class="agent-card__badge" [attr.aria-label]="'Status: ' + statusLabel()">
<span
class="status-dot"
[ngClass]="[statusDotClass()]"
></span>
<span class="agent-card__status-label">{{ statusLabel() }}</span>
</div>
<div class="agent-card__identity">
<span class="agent-card__name">{{ displayName || agentId }}</span>
<span class="agent-card__role">{{ role }}</span>
</div>
</div>
<!-- ── Body: current task ── -->
<div class="agent-card__body" *ngIf="task || status === 'error'">
<p
class="agent-card__task"
[attr.aria-label]="'Current task: ' + (status === 'error' ? errorMessage : task)"
>
{{ status === 'error' ? errorMessage || task : task }}
</p>
</div>
<!-- ── Task Progress Bar ── -->
<div
class="agent-card__progress"
*ngIf="progress > 0 && status !== 'error'"
[attr.aria-label]="'Task progress: ' + progress + '%'"
>
<mat-progress-bar
mode="determinate"
[value]="progress"
[aria-label]="'Progress ' + progress + '% complete'"
></mat-progress-bar>
<span class="agent-card__progress-label text-mono">{{ progress }}%</span>
</div>
<!-- ── Footer: channel + last activity + quickjump ── -->
<div class="agent-card__footer">
<div class="agent-card__meta">
<span
class="agent-card__channel text-mono"
[attr.aria-label]="'Channel: ' + channel"
>
<mat-icon aria-hidden="true">{{ channelIcon() }}</mat-icon>
{{ channel }}
</span>
<span
class="agent-card__last-activity text-mono"
[attr.aria-label]="'Last activity: ' + lastActivityLabel()"
>
{{ lastActivityLabel() }}
</span>
</div>
<!-- QuickJump Button -->
<a
class="agent-card__jump"
mat-button
[routerLink]="jumpRoute()"
[attr.aria-label]="'Jump to session for ' + (displayName || agentId)"
matTooltip="Jump to session"
>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
</a>
</div>
</article>

View File

@@ -0,0 +1,234 @@
// ============================================================================
// AgentCard — M3 tactical dark styling
// Per spec Section 7.3: leftborder accent, statusaware coloring,
// responsive card layout with 320px minwidth.
// ============================================================================
.agent-card {
display: flex;
flex-direction: column;
min-width: var(--cc-card-min-width);
padding: var(--cc-card-padding);
background-color: var(--cc-surface-container);
border-radius: var(--cc-card-border-radius);
border-left: 4px solid var(--status-offline); // default; overridden by [style]
border-top: 1px solid var(--cc-outline);
border-right: 1px solid var(--cc-outline);
border-bottom: 1px solid var(--cc-outline);
gap: 16px;
transition: border-left-color 0.3s ease, box-shadow 0.2s ease;
cursor: default;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
&:focus-within {
outline: 2px solid var(--status-active);
outline-offset: 2px;
}
}
// ── Header ──
.agent-card__header {
display: flex;
align-items: center;
gap: 12px;
}
.agent-card__badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
background-color: var(--status-active-bg); // overridden per status below
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--cc-on-surface);
// Perstatus background tints
.status-dot--active + & {
background-color: var(--status-active-bg);
}
}
.agent-card__status-label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--cc-on-surface-variant);
}
.agent-card__identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.agent-card__name {
font-size: 16px;
font-weight: 600;
color: var(--cc-on-surface);
line-height: 1.2;
}
.agent-card__role {
font-size: 12px;
font-weight: 400;
color: var(--cc-on-surface-variant);
}
// ── Body ──
.agent-card__body {
padding: 4px 0;
}
.agent-card__task {
margin: 0;
font-size: 14px;
font-weight: 400;
color: var(--cc-on-surface);
line-height: 1.4;
// Error messages get distinct styling
.agent-card--error & {
color: var(--status-error);
}
}
// ── Progress Bar ──
.agent-card__progress {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.agent-card__progress-label {
font-size: 12px;
font-weight: 500;
color: var(--cc-on-surface-variant);
white-space: nowrap;
min-width: 36px;
}
// Override mat-progress-bar to match tactical dark theme
.agent-card__progress ::ng-deep .mat-mdc-progress-bar {
height: 4px;
border-radius: 2px;
.mdc-linear-progress__bar-inner {
background-color: var(--status-active);
}
.mdc-linear-progress__track {
background-color: var(--cc-outline);
}
}
// ── Footer ──
.agent-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto; // push footer to bottom
}
.agent-card__meta {
display: flex;
align-items: center;
gap: 12px;
}
.agent-card__channel {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--cc-on-surface-variant);
}
.agent-card__channel-icon,
.agent-card__channel .mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
.agent-card__last-activity {
font-size: 12px;
color: var(--cc-on-surface-variant);
}
// ── QuickJump Button ──
.agent-card__jump {
flex-shrink: 0;
// Match M3 text button sizing
.mat-mdc-button {
min-width: 36px;
padding: 0 8px;
color: var(--status-active);
}
.mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
// ── Statusspecific background tints for badge ──
// We use the global status-dot classes from styles.scss and pair them
// with contextual background-color overrides here.
.agent-card[data-status="active"] .agent-card__badge,
.agent-card .status-dot--active ~ .agent-card__badge {
background-color: var(--status-active-bg);
}
.agent-card[data-status="idle"] .agent-card__badge {
background-color: var(--status-idle-bg);
}
.agent-card[data-status="thinking"] .agent-card__badge {
background-color: var(--status-thinking-bg);
}
.agent-card[data-status="error"] .agent-card__badge {
background-color: var(--status-error-bg);
}
// ── Responsive ──
@media (max-width: 599px) {
.agent-card {
min-width: unset;
padding: 16px;
}
.agent-card__header {
flex-wrap: wrap;
gap: 8px;
}
.agent-card__footer {
flex-wrap: wrap;
gap: 8px;
}
.agent-card__meta {
gap: 8px;
}
}
// ── Accessibility: reduced motion ──
@media (prefers-reduced-motion: reduce) {
.agent-card {
transition: none;
}
}

View File

@@ -0,0 +1,127 @@
import {
ChangeDetectionStrategy,
Component,
Input,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
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 Component
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
// and QuickJump Button into a card with leftborder status accent.
// ============================================================================
@Component({
selector: 'app-agent-card',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
MatTooltipModule,
],
templateUrl: './agent-card.component.html',
styleUrl: './agent-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentCardComponent {
// --- Six required inputs per spec ---
/** Agent status — drives badge color and leftborder accent */
@Input({ required: true }) status!: AgentStatus;
/** Current task description, e.g. "Reviewing PR #42" */
@Input() task = '';
/** Task progress percentage 0100 */
@Input() progress = 0;
/** Full session key for quickjump navigation */
@Input({ required: true }) sessionKey = '';
/** Communication channel, e.g. "telegram" */
@Input({ required: true }) channel = '';
/** Timestamp of last agent activity */
@Input({ required: true }) lastActivity!: Date;
// --- Additional display inputs ---
/** Short agent ID, e.g. "otto" */
@Input() agentId = '';
/** Display name, e.g. "Otto" */
@Input() displayName = '';
/** Role description, e.g. "Orchestrator Agent" */
@Input() role = '';
/** Error message (shown only when status is 'error') */
@Input() errorMessage = '';
// --- Computed values ---
/** Map status → CSS custom property for the leftborder accent */
readonly statusBorderColor = computed(() => {
const map: Record<AgentStatus, string> = {
active: 'var(--status-active)',
idle: 'var(--status-idle)',
thinking: 'var(--status-thinking)',
error: 'var(--status-error)',
offline: 'var(--status-offline)',
};
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;
});
/** 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';
});
/** Relative time string for lastActivity */
readonly lastActivityLabel = computed(() => {
if (!this.lastActivity) return '';
const now = Date.now();
const then = this.lastActivity.getTime();
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
});
/** Quickjump route derived from sessionKey */
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
}

View File

@@ -0,0 +1 @@
export * from './agent-card/agent-card.component';

View File

@@ -1,58 +0,0 @@
<article
class="agent-card"
[class]="'agent-card--' + status()"
role="article"
[attr.aria-label]="'Agent card: ' + statusLabel() + ' status'"
[style.--agent-status-color]="statusColorVar()"
[style.--agent-status-bg]="statusBgVar()"
>
<!-- Left border accent (4px, matching status color) -->
<div class="agent-card__accent"></div>
<!-- Card body -->
<div class="agent-card__body">
<!-- Header row: status badge + channel + last activity -->
<div class="agent-card__header">
<div class="agent-card__status-row">
<!-- Agent Status Badge (CUB-48) -->
<app-agent-status-badge
[status]="status()"
[showLabel]="true"
></app-agent-status-badge>
</div>
<div class="agent-card__meta">
@if (channel()) {
<span class="agent-card__channel text-mono">
<mat-icon class="agent-card__channel-icon" fontIcon="forum" [inline]="true"></mat-icon>
{{ channel() }}
</span>
}
<span class="agent-card__last-activity text-mono">{{ lastActivityText() }}</span>
</div>
</div>
<!-- Task description -->
@if (task()) {
<p class="agent-card__task">{{ task() }}</p>
}
<!-- Task Progress Bar (CUB-44) -->
@if (showProgress()) {
<app-task-progress-bar
[progress]="progress()"
[showElapsed]="false"
></app-task-progress-bar>
}
<!-- Footer: session key (truncated) + Quick-Jump Button (CUB-46) -->
<div class="agent-card__footer">
<span class="agent-card__session text-mono" [matTooltip]="sessionKey()">
{{ sessionKey().length > 28 ? sessionKey().substring(0, 28) + '…' : sessionKey() }}
</span>
<app-quick-jump-button
[sessionKey]="sessionKey()"
(jumpClick)="jumpClick.emit($event)"
></app-quick-jump-button>
</div>
</div>
</article>

View File

@@ -1,192 +0,0 @@
// ============================================================================
// Agent Card Styles — M3 Tactical Dark (Final Integration, CUB-45)
// Uses sub-components:
// - AgentStatusBadge (CUB-48) for status display
// - TaskProgressBar (CUB-44) for progress
// - QuickJumpButton (CUB-46) for navigation
// ============================================================================
// ---------------------------------------------------------------------------
// 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__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;
}
// ---------------------------------------------------------------------------
// Task Progress Bar (CUB-44 sub-component)
// Override the sub-component's progress bar colors to match status color
// ---------------------------------------------------------------------------
:host ::ng-deep .task-progress-bar .mat-mdc-progress-bar,
.agent-card .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;
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.agent-card {
transition: none;
}
}
// ---------------------------------------------------------------------------
// Responsive: Mobile adjustments
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.agent-card {
min-width: unset;
}
.agent-card__body {
padding: 16px;
gap: 8px;
}
}

View File

@@ -1,151 +0,0 @@
// ============================================================================
// Agent Card Component — Final Integration
// Per CUB-45: Compose AgentCard from all sub-components.
// - AgentStatusBadge (CUB-48) for status display
// - TaskProgressBar (CUB-44) for progress indication
// - QuickJumpButton (CUB-46) for session navigation
// Layout: left-border accent, aria-labels, role="article"
// ============================================================================
import { ChangeDetectionStrategy, Component, EventEmitter, Output, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
// Sub-components (CUB-48, CUB-44, CUB-46)
import { AgentStatusBadgeComponent } from '../agent-status-badge';
import { TaskProgressBarComponent } from '../task-progress-bar';
import { QuickJumpButtonComponent } from '../quick-jump-button';
import { AgentStatus } from '../../models';
/**
* AgentCard displays a single agent's status in the Command Hub grid.
*
* Composes three sub-components:
* - AgentStatusBadge: colored pill with pulse animation (CUB-48)
* - TaskProgressBar: determinate progress with optional elapsed time (CUB-44)
* - QuickJumpButton: M3 FilledTonal icon button for session navigation (CUB-46)
*
* Inputs:
* - status: AgentStatus — current agent status
* - task: string — current task description
* - progress: number — task progress percentage (0100)
* - sessionKey: string — full session key
* - channel: string — communication channel (e.g., "telegram")
* - lastActivity: Date — timestamp of last activity
*
* Outputs:
* - jumpClick: string — emitted when Quick-Jump button is clicked, carries sessionKey
*
* Accessibility:
* - role="article" on the card element
* - aria-label on the card summarizing status
* - aria-label on the progress bar (via TaskProgressBar)
* - aria-label on the Quick-Jump button (via QuickJumpButton)
* - focus-visible outlines for keyboard navigation
*/
@Component({
selector: 'app-agent-card',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatTooltipModule,
// Sub-components
AgentStatusBadgeComponent,
TaskProgressBarComponent,
QuickJumpButtonComponent,
],
templateUrl: './agent-card.component.html',
styleUrl: './agent-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentCardComponent {
// ---------------------------------------------------------------------------
// Inputs (all 6 required by CUB-45 definition of done)
// ---------------------------------------------------------------------------
/** Current agent status */
readonly status = input.required<AgentStatus>();
/** Current task description */
readonly task = input<string>('');
/** Task progress percentage (0100) */
readonly progress = input<number>(0);
/** Full session key */
readonly sessionKey = input.required<string>();
/** Communication channel (e.g., "telegram") */
readonly channel = input<string>('');
/** Timestamp of last activity */
readonly lastActivity = input<Date>(new Date());
// ---------------------------------------------------------------------------
// Outputs
// ---------------------------------------------------------------------------
/** Emitted when the Quick-Jump button is clicked with the session key. */
@Output() readonly jumpClick = new EventEmitter<string>();
// ---------------------------------------------------------------------------
// Computed values
// ---------------------------------------------------------------------------
/** Map status to CSS custom property name for dynamic color binding */
readonly statusColorVar = computed(() => {
const map: Record<AgentStatus, string> = {
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<AgentStatus, string> = {
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 (delegates to AgentStatusBadgeComponent) */
readonly statusLabel = computed(() => {
const map: Record<AgentStatus, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking',
error: 'Error',
offline: 'Offline',
};
return map[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;
});
}

View File

@@ -1,6 +0,0 @@
// ============================================================================
// Agent Card — Barrel Export
// CUB-45: Final integration with sub-components
// ============================================================================
export { AgentCardComponent } from './agent-card.component';

View File

@@ -1,12 +0,0 @@
<!-- Agent Status Badge: colored pill with pulse animation -->
<span
class="status-badge"
[class]="statusClass"
[attr.aria-label]="statusLabels[status] + ' status'"
[attr.role]="'status'"
>
<span class="status-badge__dot"></span>
<span *ngIf="showLabel" class="status-badge__label">
{{ statusLabels[status] }}
</span>
</span>

View File

@@ -1,143 +0,0 @@
// ============================================================================
// Agent Status Badge — Pill Style with Pulse Animation
// Per CUB-48: Color mapping + animation durations:
// Active → --color-primary (#38BDF8) 2s pulse
// Idle → --color-secondary (#2DD4BF) no animation
// Thinking → --color-accent (#A78BFA) 3s pulse
// Error → --color-danger (#F87171) 0.8s pulse
// ============================================================================
// ---------------------------------------------------------------------------
// Badge Container
// ---------------------------------------------------------------------------
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
line-height: 1;
white-space: nowrap;
user-select: none;
// --- Status-specific backgrounds & text ---
&--active {
background-color: var(--status-active-bg);
color: var(--status-active);
}
&--idle {
background-color: var(--status-idle-bg);
color: var(--status-idle);
}
&--thinking {
background-color: var(--status-thinking-bg);
color: var(--status-thinking);
}
&--error {
background-color: var(--status-error-bg);
color: var(--status-error);
}
&--offline {
background-color: rgba(100, 116, 139, 0.12);
color: var(--status-offline);
}
}
// ---------------------------------------------------------------------------
// Status Dot (inner indicator circle)
// ---------------------------------------------------------------------------
.status-badge__dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
.status-badge--active & {
background-color: var(--status-active);
animation: badge-pulse-active 2s ease-in-out infinite;
}
.status-badge--idle & {
background-color: var(--status-idle);
// Idle: no animation — steady dot
}
.status-badge--thinking & {
background-color: var(--status-thinking);
animation: badge-pulse-thinking 3s ease-in-out infinite;
}
.status-badge--error & {
background-color: var(--status-error);
animation: badge-pulse-error 0.8s ease-in-out infinite;
}
.status-badge--offline & {
background-color: var(--status-offline);
// Offline: no animation
}
}
// ---------------------------------------------------------------------------
// Label Text
// ---------------------------------------------------------------------------
.status-badge__label {
color: inherit;
}
// ---------------------------------------------------------------------------
// Badge Pulse Keyframes
// ---------------------------------------------------------------------------
// These are scoped to the badge component rather than reusing the global
// .status-dot animations, because the badge pulse is subtler (scale + opacity
// blend for a pill context vs. standalone dot context).
// ---------------------------------------------------------------------------
@keyframes badge-pulse-active {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.3);
}
}
@keyframes badge-pulse-thinking {
0%,
100% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
}
@keyframes badge-pulse-error {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.status-badge__dot {
animation: none !important;
}
}

View File

@@ -1,51 +0,0 @@
// ============================================================================
// Agent Status Badge Component
// Per CUB-48: Colored pill badge with pulse animation for agent statuses.
// Displays Active, Idle, Thinking, or Error with correct color mapping.
// ============================================================================
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AgentStatus } from '../../models';
/**
* Reusable status badge that renders a colored pill with label text
* and a subtle pulse animation for Active, Thinking, and Error states.
*
* Usage:
* ```html
* <app-agent-status-badge status="active" />
* <app-agent-status-badge status="error" />
* ```
*/
@Component({
selector: 'app-agent-status-badge',
standalone: true,
imports: [CommonModule],
templateUrl: './agent-status-badge.component.html',
styleUrl: './agent-status-badge.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentStatusBadgeComponent {
/** Agent status to display. Maps to a color and animation. */
@Input({ required: true })
status!: AgentStatus;
/** Whether to show the status label text alongside the dot. Defaults to true. */
@Input()
showLabel = true;
/** Mapping from status to human-readable label. */
readonly statusLabels: Record<AgentStatus, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking',
error: 'Error',
offline: 'Offline',
};
/** CSS class string for the badge container based on current status. */
get statusClass(): string {
return `status-badge--${this.status}`;
}
}

View File

@@ -1,6 +0,0 @@
// ============================================================================
// Agent Status Badge — Barrel Export
// CUB-48
// ============================================================================
export { AgentStatusBadgeComponent } from './agent-status-badge.component';

View File

@@ -1,8 +0,0 @@
// ============================================================================
// Components Barrel Export
// ============================================================================
export * from './agent-card';
export * from './agent-status-badge';
export * from './task-progress-bar';
export * from './quick-jump-button';

View File

@@ -1,6 +0,0 @@
// ============================================================================
// Quick-Jump Button — Barrel Export
// CUB-46
// ============================================================================
export { QuickJumpButtonComponent } from './quick-jump-button.component';

View File

@@ -1,8 +0,0 @@
<button
mat-icon-button
class="quick-jump-button"
[attr.aria-label]="'Jump to agent session'"
(click)="onJumpClick()"
>
<mat-icon>arrow_forward</mat-icon>
</button>

View File

@@ -1,68 +0,0 @@
// ============================================================================
// Quick-Jump Button — M3 FilledTonalIconButton
// Per spec Section 7.3: Agent Card Quick-Jump action
// M3 spec: FilledTonalIconButton uses secondary container color
// with 8% state layer overlay for hover/focus.
// ============================================================================
.quick-jump-button {
// M3 FilledTonalIconButton: secondary-container background
// Angular Material mat-icon-button sets up the base shape (40x40, round).
// We override the color tokens to match FilledTonal style.
--mdc-icon-button-icon-color: var(--mat-sys-on-secondary-container);
background-color: var(--mat-sys-secondary-container);
border-radius: 50%;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
// M3 State Layer: 8% overlay on hover
&:hover {
background-color: var(--mat-sys-secondary-container);
// State layer overlay using a pseudo-element for precise 8% opacity
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--mat-sys-on-secondary-container);
opacity: 0.08;
pointer-events: none;
}
}
// M3 State Layer: 12% overlay on focus-visible (slightly stronger for accessibility)
&:focus-visible {
background-color: var(--mat-sys-secondary-container);
outline: 3px solid var(--status-active);
outline-offset: 2px;
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--mat-sys-on-secondary-container);
opacity: 0.12;
pointer-events: none;
}
}
// M3 State Layer: 12% overlay on active/pressed
&:active {
background-color: var(--mat-sys-secondary-container);
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background-color: var(--mat-sys-on-secondary-container);
opacity: 0.12;
pointer-events: none;
}
}
// Icon color stays on-secondary-container across all states
.mat-icon {
color: var(--mat-sys-on-secondary-container);
}
}

View File

@@ -1,33 +0,0 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
/**
* Quick-Jump Button — M3 FilledTonalIconButton
*
* An icon button that emits a navigation event for jumping to an agent session.
* Uses the Material Design 3 FilledTonalIconButton style with 8% state layer
* overlay on hover and focus.
*
* Per spec Section 7.3: Agent Card Component Interface
*/
@Component({
selector: 'app-quick-jump-button',
standalone: true,
imports: [MatIconButton, MatIcon],
templateUrl: './quick-jump-button.component.html',
styleUrl: './quick-jump-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuickJumpButtonComponent {
/** The session key to navigate to. Set by the parent agent card. */
@Input()
sessionKey = '';
/** Emitted when the button is clicked, carrying the session key for navigation. */
@Output() jumpClick = new EventEmitter<string>();
onJumpClick(): void {
this.jumpClick.emit(this.sessionKey);
}
}

View File

@@ -1,6 +0,0 @@
// ============================================================================
// Task Progress Bar — Barrel Export
// CUB-44
// ============================================================================
export { TaskProgressBarComponent } from './task-progress-bar.component';

View File

@@ -1,18 +0,0 @@
<!-- Task Progress Bar: determinate progress with optional elapsed time -->
<div class="task-progress-bar">
<!-- Info row: percentage + optional elapsed -->
<div class="task-progress-bar__info">
<span class="task-progress-bar__percent">{{ clampedProgress }}%</span>
<span *ngIf="showElapsed" class="task-progress-bar__elapsed">
{{ elapsedText }}
</span>
</div>
<!-- Angular Material determinate progress bar -->
<mat-progress-bar
class="task-progress-bar__bar"
mode="determinate"
[value]="clampedProgress"
aria-label="Task progress"
></mat-progress-bar>
</div>

View File

@@ -1,77 +0,0 @@
// ============================================================================
// Task Progress Bar — Tactical Dark Theme Styling
// Per CUB-44: Uses --color-primary for bar fill and --color-surface-light
// for track background, mapped to the Control Center's M3 dark tokens.
// ============================================================================
// ---------------------------------------------------------------------------
// Container
// ---------------------------------------------------------------------------
.task-progress-bar {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
// ---------------------------------------------------------------------------
// Info row: percentage label + elapsed time
// ---------------------------------------------------------------------------
.task-progress-bar__info {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.task-progress-bar__percent {
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
font-size: 14px;
font-weight: 600;
color: var(--cc-on-surface, #E2E8F0);
letter-spacing: 0.02em;
}
.task-progress-bar__elapsed {
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
font-size: 12px;
font-weight: 400;
color: var(--cc-on-surface-variant, #8A9BB0);
letter-spacing: 0.01em;
}
// ---------------------------------------------------------------------------
// Material Progress Bar Overrides
// ---------------------------------------------------------------------------
// Map the spec's --color-primary and --color-surface-light to the Control
// Center's actual theme tokens. This ensures the bar uses the tactical dark
// palette while respecting the spec's variable naming.
// ---------------------------------------------------------------------------
.task-progress-bar__bar {
// Override the track (background) to use the surface container
--mat-progress-bar-track-height: 6px;
--mat-progress-bar-active-indicator-height: 6px;
// Bar fill color: primary (cyan/sky blue per tactical dark theme)
--mat-progress-bar-active-indicator-color: var(--color-primary, var(--mat-sys-primary, #38BDF8));
// Track background: surface container (dark slate)
--mat-progress-bar-track-color: var(--color-surface-light, var(--cc-surface-container, #1C2027));
// Border radius for a softer bar
border-radius: 3px;
// Smooth transition on value changes
transition: none;
}
// Rounded ends on the progress bar fill
:host ::ng-deep .mdc-linear-progress__bar-inner {
border-radius: 3px;
}
// Rounded track background
:host ::ng-deep .mdc-linear-progress__track {
border-radius: 3px;
}

View File

@@ -1,109 +0,0 @@
// ============================================================================
// Task Progress Bar Component
// Per CUB-44: Determinate progress bar with optional elapsed time display.
// Uses Angular Material mat-progress-bar in determinate mode with tactical
// dark theme styling via CSS custom properties.
// ============================================================================
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatProgressBarModule } from '@angular/material/progress-bar';
/**
* Displays a determinate progress bar with an optional elapsed time indicator.
*
* Usage:
* ```html
* <app-task-progress-bar [progress]="65" />
* <app-task-progress-bar [progress]="42" [showElapsed]="true" />
* ```
*/
@Component({
selector: 'app-task-progress-bar',
standalone: true,
imports: [CommonModule, MatProgressBarModule],
templateUrl: './task-progress-bar.component.html',
styleUrl: './task-progress-bar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskProgressBarComponent implements OnInit, OnDestroy {
// ---------------------------------------------------------------------------
// Inputs
// ---------------------------------------------------------------------------
/** Current progress percentage (0100). Required. */
@Input({ required: true })
progress!: number;
/** Whether to show elapsed time next to the percentage. Defaults to false. */
@Input()
showElapsed = false;
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
/** Timestamp when the component initialized — used for elapsed calculation. */
startTime = Date.now();
/** Formatted elapsed time string, e.g. "2m 15s ago". */
elapsedText = '';
/** Interval timer for updating the elapsed display. */
private timer: ReturnType<typeof setInterval> | null = null;
constructor(private cdr: ChangeDetectorRef) {}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
ngOnInit(): void {
this.updateElapsed();
if (this.showElapsed) {
// Update elapsed time every second
this.timer = setInterval(() => {
this.updateElapsed();
this.cdr.markForCheck();
}, 1000);
}
}
ngOnDestroy(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Clamp progress to 0100 for safety. */
get clampedProgress(): number {
return Math.max(0, Math.min(100, this.progress ?? 0));
}
/** Recalculate the elapsed time string. */
private updateElapsed(): void {
const elapsedMs = Date.now() - this.startTime;
const totalSeconds = Math.floor(elapsedMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
this.elapsedText = `${minutes}m ${seconds}s ago`;
} else {
this.elapsedText = `${seconds}s ago`;
}
}
}

View File

@@ -1,88 +1,26 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { AgentCardComponent } from '../../components/agent-card/agent-card.component';
import { AgentCardData } from '../../models/agent.model';
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-hub-page',
standalone: true,
imports: [AgentCardComponent],
imports: [],
template: `
<div class="hub-page">
<div class="hub-page__grid">
@for (agent of agents(); track agent.id) {
<app-agent-card
[status]="agent.status"
[task]="agent.currentTask ?? ''"
[progress]="agent.taskProgress ?? 0"
[sessionKey]="agent.sessionKey"
[channel]="agent.channel"
[lastActivity]="agent.lastActivity"
/>
}
</div>
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
</div>
`,
styles: [`
.hub-page {
padding: var(--cc-section-padding, 24px);
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
.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);
.hub-page__placeholder {
color: var(--cc-on-surface-variant);
font-size: 16px;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HubPageComponent {
/** Demo agents for development — will be replaced by SignalR data */
protected readonly agents = signal<AgentCardData[]>([
{
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',
},
]);
}
export class HubPageComponent {}

View File

@@ -48,17 +48,6 @@ html {
// These are NOT part of the M3 tonal palette; they are semantic overrides.
// ---------------------------------------------------------------------------
:root {
// --- Tactical Dark Mode color palette (CUB-47) ---
--color-surface: #0F172A;
--color-surface-light: #1E293B;
--color-primary: #38BDF8;
--color-secondary: #2DD4BF;
--color-accent: #A78BFA;
--color-danger: #F87171;
--color-text-primary: #FFFFFF;
--color-text-secondary: #94A3B8;
--color-border: #334155;
// --- Status colors ---
--status-active: #38BDF8;
--status-idle: #2DD4BF;
@@ -101,7 +90,7 @@ html {
// Global Body Styles
// ---------------------------------------------------------------------------
body {
background-color: var(--color-surface);
background-color: var(--cc-background);
color: var(--cc-on-surface);
font-family: 'Inter', 'Roboto', sans-serif;
margin: 0;