Compare commits
1 Commits
agent/hex/
...
8341503a39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8341503a39 |
@@ -13,7 +13,6 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!-- ============================================================================
|
||||
Agent Card — Final Integration (CUB-45)
|
||||
Assembles: AgentStatusBadge, TaskProgressBar, QuickJumpButton
|
||||
Layout: left-border accent, aria-labels, role="article"
|
||||
========================================================================== -->
|
||||
|
||||
<article
|
||||
class="agent-card"
|
||||
[class]="'agent-card--' + status()"
|
||||
[attr.aria-label]="'Agent card: ' + status() + ' status, task: ' + (task() || 'none')"
|
||||
[attr.role]="'article'"
|
||||
[style.--agent-status-color]="statusColorVar()"
|
||||
[style.--agent-status-bg]="statusBgVar()"
|
||||
>
|
||||
<!-- Left border accent (4px, matching status color) -->
|
||||
<div
|
||||
class="agent-card__accent"
|
||||
[attr.aria-hidden]="'true'"
|
||||
></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">
|
||||
<!-- Sub-component: Agent Status Badge -->
|
||||
<app-agent-status-badge
|
||||
[status]="status()"
|
||||
[attr.aria-label]="status() + ' status'"
|
||||
/>
|
||||
</div>
|
||||
<div class="agent-card__meta">
|
||||
@if (channel()) {
|
||||
<span
|
||||
class="agent-card__channel text-mono"
|
||||
[attr.aria-label]="'Channel: ' + channel()"
|
||||
>
|
||||
<mat-icon
|
||||
class="agent-card__channel-icon"
|
||||
fontIcon="forum"
|
||||
[inline]="true"
|
||||
[attr.aria-hidden]="'true'"
|
||||
></mat-icon>
|
||||
{{ channel() }}
|
||||
</span>
|
||||
}
|
||||
<span
|
||||
class="agent-card__last-activity text-mono"
|
||||
[attr.aria-label]="'Last activity: ' + lastActivityText()"
|
||||
>
|
||||
{{ lastActivityText() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task description -->
|
||||
@if (task()) {
|
||||
<p
|
||||
class="agent-card__task"
|
||||
[attr.aria-label]="'Current task: ' + task()"
|
||||
>
|
||||
{{ task() }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Sub-component: Task Progress Bar -->
|
||||
@if (showProgress()) {
|
||||
<div
|
||||
class="agent-card__progress"
|
||||
[attr.aria-label]="'Task progress: ' + progress() + '%'"
|
||||
>
|
||||
<app-task-progress-bar
|
||||
[progress]="progress()"
|
||||
[showElapsed]="showElapsed()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer: session key (truncated) + Quick-Jump button -->
|
||||
<div class="agent-card__footer">
|
||||
<span
|
||||
class="agent-card__session text-mono"
|
||||
[matTooltip]="sessionKey()"
|
||||
[attr.aria-label]="'Session key: ' + sessionKey()"
|
||||
>
|
||||
{{ truncatedSessionKey() }}
|
||||
</span>
|
||||
|
||||
<!-- Sub-component: Quick-Jump Button -->
|
||||
<app-quick-jump-button
|
||||
[sessionKey]="sessionKey()"
|
||||
(jumpClick)="onJumpClick($event)"
|
||||
[attr.aria-label]="'Jump to agent session'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
196
frontend/src/app/components/agent-card/agent-card.component.scss
Normal file
196
frontend/src/app/components/agent-card/agent-card.component.scss
Normal file
@@ -0,0 +1,196 @@
|
||||
// ============================================================================
|
||||
// Agent Card Styles — M3 Tactical Dark (CUB-45 Final Integration)
|
||||
// Assembles: AgentStatusBadge, TaskProgressBar, QuickJumpButton
|
||||
// Uses CUB-47 dark mode CSS variables.
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
// Uses --agent-status-color set via [style.--agent-status-color] binding
|
||||
// in the template. Color map:
|
||||
// Active → --status-active (#38BDF8)
|
||||
// Idle → --status-idle (#2DD4BF)
|
||||
// Thinking → --status-thinking (#A78BFA)
|
||||
// Error → --status-error (#F87171)
|
||||
// ---------------------------------------------------------------------------
|
||||
.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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress Bar Container
|
||||
// Overrides the sub-component's bar to use the agent's status color
|
||||
// ---------------------------------------------------------------------------
|
||||
.agent-card__progress {
|
||||
// Override TaskProgressBar's active indicator to match agent status color
|
||||
--mat-progress-bar-active-indicator-color: var(--agent-status-color);
|
||||
--mat-progress-bar-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;
|
||||
}
|
||||
}
|
||||
160
frontend/src/app/components/agent-card/agent-card.component.ts
Normal file
160
frontend/src/app/components/agent-card/agent-card.component.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// ============================================================================
|
||||
// Agent Card Component — Final Integration (CUB-45)
|
||||
// Assembles: AgentStatusBadge, TaskProgressBar, QuickJumpButton
|
||||
// Layout: left-border accent, aria-labels, role="article"
|
||||
// Uses tactical dark mode CSS variables from CUB-47.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Output,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
import { AgentStatus } from '../../models';
|
||||
import { AgentStatusBadgeComponent } from '../agent-status-badge/agent-status-badge.component';
|
||||
import { TaskProgressBarComponent } from '../task-progress-bar/task-progress-bar.component';
|
||||
import { QuickJumpButtonComponent } from '../quick-jump-button/quick-jump-button.component';
|
||||
|
||||
/**
|
||||
* AgentCard displays a single agent's status in the Command Hub grid.
|
||||
*
|
||||
* Composes three sub-components:
|
||||
* - AgentStatusBadge — colored pill with pulse animation
|
||||
* - TaskProgressBar — determinate progress with optional elapsed time
|
||||
* - QuickJumpButton — M3 FilledTonalIconButton for session navigation
|
||||
*
|
||||
* Inputs:
|
||||
* - status: AgentStatus — current agent status (required)
|
||||
* - task: string — current task description
|
||||
* - progress: number — task progress percentage (0–100)
|
||||
* - sessionKey: string — full session key (required)
|
||||
* - 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, progress bar, and jump button
|
||||
* - focus-visible outlines for keyboard navigation
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-agent-card',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
AgentStatusBadgeComponent,
|
||||
TaskProgressBarComponent,
|
||||
QuickJumpButtonComponent,
|
||||
],
|
||||
templateUrl: './agent-card.component.html',
|
||||
styleUrl: './agent-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AgentCardComponent {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Required Inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Current agent status — drives left-border accent color and badge. */
|
||||
readonly status = input.required<AgentStatus>();
|
||||
|
||||
/** Full session key — displayed subtly and passed to QuickJumpButton. */
|
||||
readonly sessionKey = input.required<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional Inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Current task description, e.g. "Reviewing PR #42". */
|
||||
readonly task = input<string>('');
|
||||
|
||||
/** Task progress percentage (0–100). Shown when status is active/thinking. */
|
||||
readonly progress = input<number>(0);
|
||||
|
||||
/** Communication channel, e.g. "telegram". */
|
||||
readonly channel = input<string>('');
|
||||
|
||||
/** Timestamp of last activity. Formatted as relative time. */
|
||||
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 for dynamic left-border accent color. */
|
||||
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()];
|
||||
});
|
||||
|
||||
/** 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 the progress bar — visible for active/thinking with progress > 0. */
|
||||
readonly showProgress = computed(() => {
|
||||
const s = this.status();
|
||||
return (s === 'active' || s === 'thinking') && this.progress() > 0;
|
||||
});
|
||||
|
||||
/** Whether to show the elapsed time on the progress bar. */
|
||||
readonly showElapsed = computed(() => this.showProgress());
|
||||
|
||||
/** Truncate session key for display. */
|
||||
readonly truncatedSessionKey = computed(() => {
|
||||
const key = this.sessionKey();
|
||||
return key.length > 28 ? key.substring(0, 28) + '…' : key;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Forward QuickJumpButton's jump event with the session key. */
|
||||
onJumpClick(sessionKey: string): void {
|
||||
this.jumpClick.emit(sessionKey);
|
||||
}
|
||||
}
|
||||
4
frontend/src/app/components/agent-card/index.ts
Normal file
4
frontend/src/app/components/agent-card/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// ============================================================================
|
||||
// Agent Card — Barrel Export (CUB-45)
|
||||
// ============================================================================
|
||||
export { AgentCardComponent } from './agent-card.component';
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,143 @@
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// ============================================================================
|
||||
// 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}`;
|
||||
}
|
||||
}
|
||||
6
frontend/src/app/components/agent-status-badge/index.ts
Normal file
6
frontend/src/app/components/agent-status-badge/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// ============================================================================
|
||||
// Agent Status Badge — Barrel Export
|
||||
// CUB-48
|
||||
// ============================================================================
|
||||
|
||||
export { AgentStatusBadgeComponent } from './agent-status-badge.component';
|
||||
7
frontend/src/app/components/index.ts
Normal file
7
frontend/src/app/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ============================================================================
|
||||
// Component Barrel Exports
|
||||
// ============================================================================
|
||||
export * from './agent-card/index';
|
||||
export * from './agent-status-badge/index';
|
||||
export * from './quick-jump-button/quick-jump-button.component';
|
||||
export * from './task-progress-bar/index';
|
||||
@@ -0,0 +1,8 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
class="quick-jump-button"
|
||||
[attr.aria-label]="'Jump to agent session'"
|
||||
(click)="onJumpClick()"
|
||||
>
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
@@ -0,0 +1,68 @@
|
||||
// ============================================================================
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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 {
|
||||
/** Emitted when the button is clicked, carrying the session key for navigation. */
|
||||
@Output() jumpClick = new EventEmitter<string>();
|
||||
|
||||
/** The session key to navigate to. Set by the parent agent card. */
|
||||
@Input()
|
||||
sessionKey = '';
|
||||
|
||||
onJumpClick(): void {
|
||||
this.jumpClick.emit(this.sessionKey);
|
||||
}
|
||||
}
|
||||
6
frontend/src/app/components/task-progress-bar/index.ts
Normal file
6
frontend/src/app/components/task-progress-bar/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// ============================================================================
|
||||
// Task Progress Bar — Barrel Export
|
||||
// CUB-44
|
||||
// ============================================================================
|
||||
|
||||
export { TaskProgressBarComponent } from './task-progress-bar.component';
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,77 @@
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// ============================================================================
|
||||
// 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 (0–100). 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 0–100 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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,17 @@ 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;
|
||||
@@ -90,7 +101,7 @@ html {
|
||||
// Global Body Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
body {
|
||||
background-color: var(--cc-background);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--cc-on-surface);
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
margin: 0;
|
||||
|
||||
Reference in New Issue
Block a user