From e84a479e33524b91beaee728d69e539da8f2b3df Mon Sep 17 00:00:00 2001 From: rex-bot Date: Tue, 28 Apr 2026 09:14:30 -0400 Subject: [PATCH] CUB-26: Quick-jump drawer and modal components --- .../agent-card/agent-card.component.html | 5 + .../agent-card/agent-card.component.scss | 8 + .../agent-card/agent-card.component.ts | 13 + .../agent-session-drawer.component.html | 140 +++++ .../agent-session-drawer.component.scss | 500 ++++++++++++++++++ .../agent-session-drawer.component.ts | 268 ++++++++++ .../components/agent-session-drawer/index.ts | 2 + frontend/src/app/components/index.ts | 4 +- frontend/src/app/directives/index.ts | 1 + .../app/directives/long-press.directive.ts | 89 ++++ .../layout-shell/layout-shell.component.html | 9 +- .../layout-shell/layout-shell.component.ts | 55 +- .../src/app/pages/hub/hub-page.component.scss | 11 +- .../src/app/pages/hub/hub-page.component.ts | 154 +++++- 14 files changed, 1248 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.html create mode 100644 frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.scss create mode 100644 frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.ts create mode 100644 frontend/src/app/components/agent-session-drawer/index.ts create mode 100644 frontend/src/app/directives/index.ts create mode 100644 frontend/src/app/directives/long-press.directive.ts diff --git a/frontend/src/app/command-hub/components/agent-card/agent-card.component.html b/frontend/src/app/command-hub/components/agent-card/agent-card.component.html index 8140132..6b49bfc 100644 --- a/frontend/src/app/command-hub/components/agent-card/agent-card.component.html +++ b/frontend/src/app/command-hub/components/agent-card/agent-card.component.html @@ -8,6 +8,11 @@ role="article" [attr.aria-label]="displayName + ' — ' + statusLabel()" [style.border-left-color]="statusBorderColor()" + (click)="cardClick.emit(sessionKey)" + appLongPress + [appLongPressDuration]="500" + (appLongPress)="cardLongPress.emit(sessionKey)" + tabindex="0" > diff --git a/frontend/src/app/command-hub/components/agent-card/agent-card.component.scss b/frontend/src/app/command-hub/components/agent-card/agent-card.component.scss index 2846e74..450eba6 100644 --- a/frontend/src/app/command-hub/components/agent-card/agent-card.component.scss +++ b/frontend/src/app/command-hub/components/agent-card/agent-card.component.scss @@ -23,6 +23,14 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } + // CUB-26: Card is now clickable for session drawer + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + &:focus-within { outline: 2px solid var(--status-active); outline-offset: 2px; diff --git a/frontend/src/app/command-hub/components/agent-card/agent-card.component.ts b/frontend/src/app/command-hub/components/agent-card/agent-card.component.ts index a378e62..e1c44b4 100644 --- a/frontend/src/app/command-hub/components/agent-card/agent-card.component.ts +++ b/frontend/src/app/command-hub/components/agent-card/agent-card.component.ts @@ -1,7 +1,9 @@ import { ChangeDetectionStrategy, Component, + EventEmitter, Input, + Output, computed, } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -11,11 +13,13 @@ 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 { LongPressDirective } from '../../../directives/long-press.directive'; // ============================================================================ // AgentCard Component // Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar, // and Quick‑Jump Button into a card with left‑border status accent. +// CUB-26: Emits cardClick and cardLongPress for drawer/modal integration. // ============================================================================ @Component({ @@ -28,6 +32,7 @@ import { AgentStatus } from '../../../models/agent.model'; MatButtonModule, MatProgressBarModule, MatTooltipModule, + LongPressDirective, ], templateUrl: './agent-card.component.html', styleUrl: './agent-card.component.scss', @@ -68,6 +73,14 @@ export class AgentCardComponent { /** Error message (shown only when status is 'error') */ @Input() errorMessage = ''; + // --- CUB-26: Outputs for drawer/modal integration --- + + /** Emitted when the card is clicked — opens the session drawer. */ + @Output() readonly cardClick = new EventEmitter(); + + /** Emitted when the card is long-pressed — bypasses drawer, opens session log directly. */ + @Output() readonly cardLongPress = new EventEmitter(); + // --- Computed values --- /** Map status → CSS custom property for the left‑border accent */ diff --git a/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.html b/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.html new file mode 100644 index 0000000..9b343e2 --- /dev/null +++ b/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.html @@ -0,0 +1,140 @@ + + + +@if (isOpen()) { +
+} + + + \ No newline at end of file diff --git a/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.scss b/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.scss new file mode 100644 index 0000000..b98dc62 --- /dev/null +++ b/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.scss @@ -0,0 +1,500 @@ +// ============================================================================ +// Agent Session Drawer — CUB-26 +// Desktop: 480px side drawer slides from right with modal overlay. +// Mobile: Bottom sheet slides up from bottom. +// Uses Control Center design tokens from CUB-21. +// ============================================================================ + +// --------------------------------------------------------------------------- +// Backdrop +// --------------------------------------------------------------------------- +.session-drawer-backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 998; + opacity: 0; + transition: opacity 200ms ease-out; + + &--visible { + opacity: 1; + } +} + +// --------------------------------------------------------------------------- +// Drawer Panel — Desktop: Side drawer from right +// --------------------------------------------------------------------------- +.session-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 480px; + max-width: 100vw; + background-color: var(--cc-surface-container); + border-left: 1px solid var(--cc-outline); + z-index: 999; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4); + overflow: hidden; + + &--open { + transform: translateX(0); + } + + // --------------------------------------------------------------------------- + // Mobile: Bottom Sheet — slides up from bottom + // --------------------------------------------------------------------------- + &--mobile { + top: auto; + right: 0; + bottom: 0; + left: 0; + width: 100%; + max-width: 100vw; + max-height: 85vh; + border-left: none; + border-top: 1px solid var(--cc-outline); + border-radius: 20px 20px 0 0; + transform: translateY(100%); + box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4); + + &.session-drawer--open { + transform: translateY(0); + } + + // Drag handle for mobile bottom sheet + &::before { + content: ''; + display: block; + width: 32px; + height: 4px; + border-radius: 2px; + background: var(--cc-on-surface-variant); + opacity: 0.4; + margin: 8px auto 4px; + } + } +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- +.session-drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--cc-outline); + flex-shrink: 0; +} + +.session-drawer__header-identity { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + flex: 1; +} + +.session-drawer__header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.session-drawer__title { + font-size: 20px; + font-weight: 600; + color: var(--cc-on-surface); + margin: 0; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-drawer__role { + font-size: 13px; + color: var(--cc-on-surface-variant); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-drawer__close-btn { + --mat-icon-button-state-layer-color: transparent; + color: var(--cc-on-surface-variant); + flex-shrink: 0; + + &:hover { + color: var(--cc-on-surface); + } + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } +} + +// --------------------------------------------------------------------------- +// Content — scrollable area +// --------------------------------------------------------------------------- +.session-drawer__content { + flex: 1; + overflow-y: auto; + padding: 0 24px 16px; +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- +.session-drawer__section { + padding: 16px 0; + border-bottom: 1px solid var(--cc-outline); + + &:last-child { + border-bottom: none; + } +} + +.session-drawer__section-title { + font-size: 13px; + font-weight: 600; + color: var(--cc-on-surface-variant); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 12px; +} + +// --------------------------------------------------------------------------- +// Meta Row — Status + Channel +// --------------------------------------------------------------------------- +.session-drawer__meta-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.session-drawer__status-chip { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + + &.status-chip--active { + background-color: var(--status-active-bg); + color: var(--status-active); + } + + &.status-chip--idle { + background-color: var(--status-idle-bg); + color: var(--status-idle); + } + + &.status-chip--thinking { + background-color: var(--status-thinking-bg); + color: var(--status-thinking); + } + + &.status-chip--error { + background-color: var(--status-error-bg); + color: var(--status-error); + } + + &.status-chip--offline { + background-color: rgba(100, 116, 139, 0.12); + color: var(--status-offline); + } +} + +.session-drawer__channel-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 8px; + background: var(--cc-surface-container-high); + font-size: 12px; + color: var(--cc-on-surface-variant); +} + +.session-drawer__channel-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + +// --------------------------------------------------------------------------- +// Session Key +// --------------------------------------------------------------------------- +.session-drawer__session-key { + margin-bottom: 12px; +} + +.session-drawer__label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--cc-on-surface-variant); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 4px; +} + +.session-drawer__key-value { + display: block; + font-family: var(--cc-font-mono); + font-size: 12px; + color: var(--color-secondary); + background: var(--cc-surface); + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--cc-outline); + word-break: break-all; + line-height: 1.5; + user-select: all; +} + +// --------------------------------------------------------------------------- +// Task Info +// --------------------------------------------------------------------------- +.session-drawer__task-info { + margin-bottom: 12px; +} + +.session-drawer__task-text { + font-size: 14px; + color: var(--cc-on-surface); + line-height: 1.4; +} + +// --------------------------------------------------------------------------- +// Last Activity +// --------------------------------------------------------------------------- +.session-drawer__last-activity { + margin-bottom: 0; +} + +.session-drawer__activity-time { + font-size: 14px; + color: var(--cc-on-surface); +} + +// --------------------------------------------------------------------------- +// Recent Messages +// --------------------------------------------------------------------------- +.session-drawer__messages { + display: flex; + flex-direction: column; + gap: 10px; +} + +.session-drawer__message { + padding: 10px 14px; + border-radius: 12px; + max-width: 100%; + + &--agent { + background: var(--cc-surface-container-high); + border: 1px solid var(--cc-outline); + } + + &--user { + background: rgba(56, 189, 248, 0.08); + border: 1px solid rgba(56, 189, 248, 0.15); + } +} + +.session-drawer__message-sender { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--cc-on-surface-variant); + margin-bottom: 4px; +} + +.session-drawer__message-text { + font-size: 14px; + color: var(--cc-on-surface); + margin: 0; + line-height: 1.5; + word-wrap: break-word; +} + +.session-drawer__message-time { + display: block; + font-size: 11px; + color: var(--cc-on-surface-variant); + margin-top: 4px; + font-family: var(--cc-font-mono); +} + +// --------------------------------------------------------------------------- +// Live Log Container +// --------------------------------------------------------------------------- +.session-drawer__log-container { + background: var(--cc-surface); + border: 1px solid var(--cc-outline); + border-radius: 10px; + padding: 10px 12px; + max-height: 260px; + overflow-y: auto; + font-family: var(--cc-font-mono); + font-size: 12px; + line-height: 1.7; +} + +.session-drawer__log-line { + display: flex; + gap: 8px; + padding: 1px 0; + white-space: nowrap; + + &--info { + color: var(--cc-on-surface); + } + + &--warn { + color: #FBBF24; + } + + &--error { + color: var(--status-error); + } + + &--debug { + color: var(--cc-on-surface-variant); + } +} + +.session-drawer__log-time { + color: var(--cc-on-surface-variant); + flex-shrink: 0; + opacity: 0.7; +} + +.session-drawer__log-level { + width: 48px; + flex-shrink: 0; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.02em; +} + +.session-drawer__log-message { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; +} + +// --------------------------------------------------------------------------- +// Empty State +// --------------------------------------------------------------------------- +.session-drawer__empty-state { + font-size: 13px; + color: var(--cc-on-surface-variant); + text-align: center; + padding: 16px 0; + margin: 0; +} + +// --------------------------------------------------------------------------- +// Action Buttons — Sticky Footer +// --------------------------------------------------------------------------- +.session-drawer__actions { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px 20px; + border-top: 1px solid var(--cc-outline); + background-color: var(--cc-surface-container); + flex-shrink: 0; +} + +.session-drawer__action-btn { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 500; + + .mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } + + &--primary { + flex: 1; + } + + &--secondary { + flex-shrink: 0; + } + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } +} + +// --------------------------------------------------------------------------- +// Mobile Adjustments +// --------------------------------------------------------------------------- +.session-drawer--mobile { + .session-drawer__header { + padding: 12px 20px 12px; + } + + .session-drawer__content { + padding: 0 20px 12px; + } + + .session-drawer__actions { + padding: 12px 20px 16px; + flex-direction: column; + + .session-drawer__action-btn { + width: 100%; + justify-content: center; + } + } + + .session-drawer__log-container { + max-height: 200px; + } +} + +// --------------------------------------------------------------------------- +// Responsive — wider viewports keep 480px, narrow go full-width +// --------------------------------------------------------------------------- +@media (max-width: 599px) { + .session-drawer:not(.session-drawer--mobile) { + width: 100%; + max-width: 100vw; + } +} + +// --------------------------------------------------------------------------- +// Accessibility: Reduced Motion +// --------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .session-drawer { + transition: none; + } + + .session-drawer-backdrop { + transition: none; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.ts b/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.ts new file mode 100644 index 0000000..2442e48 --- /dev/null +++ b/frontend/src/app/components/agent-session-drawer/agent-session-drawer.component.ts @@ -0,0 +1,268 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnDestroy, + Output, + signal, + ViewChild, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { AgentCardData, AgentStatus } from '../../models/agent.model'; + +// ============================================================================ +// Agent Session Drawer — Per CUB-26 +// Desktop: 480px side drawer slides from right with modal overlay. +// Mobile: Bottom sheet slides up from bottom. +// Shows: Agent name, status badge, session key, live log tail, +// recent messages, and action buttons. +// ============================================================================ + +export interface SessionLogLine { + timestamp: Date; + level: 'info' | 'warn' | 'error' | 'debug'; + message: string; +} + +export interface SessionMessage { + id: string; + sender: 'agent' | 'user'; + content: string; + timestamp: Date; +} + +@Component({ + selector: 'app-agent-session-drawer', + standalone: true, + imports: [CommonModule, MatButtonModule, MatIconModule, MatChipsModule], + templateUrl: './agent-session-drawer.component.html', + styleUrl: './agent-session-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AgentSessionDrawerComponent implements OnDestroy { + // --------------------------------------------------------------------------- + // Inputs + // --------------------------------------------------------------------------- + + /** The agent whose session details are displayed. */ + @Input() set agent(value: AgentCardData | null) { + this._agent = value; + if (value) { + this.isOpen.set(true); + this.loadSessionData(value); + } + } + get agent(): AgentCardData | null { + return this._agent; + } + private _agent: AgentCardData | null = null; + + /** Whether this is mobile viewport (bottom sheet mode). */ + @Input() isMobile = false; + + // --------------------------------------------------------------------------- + // Outputs + // --------------------------------------------------------------------------- + + /** Emitted when the user clicks "Open Full Session". Payload is the session key. */ + @Output() readonly openSession = new EventEmitter(); + + /** Emitted when the user clicks "Pin to Dashboard". Payload is the session key. */ + @Output() readonly pinToDashboard = new EventEmitter(); + + /** Emitted when the drawer closes. */ + @Output() readonly drawerClose = new EventEmitter(); + + // --------------------------------------------------------------------------- + // Signals + // --------------------------------------------------------------------------- + + readonly isOpen = signal(false); + readonly logLines = signal([]); + readonly recentMessages = signal([]); + + // --------------------------------------------------------------------------- + // View Children + // --------------------------------------------------------------------------- + + @ViewChild('drawerPanel') drawerPanel!: ElementRef; + + // --------------------------------------------------------------------------- + // Status Helpers + // --------------------------------------------------------------------------- + + getStatusClass(status: string): string { + return `status-dot--${status}`; + } + + getStatusLabel(status: AgentStatus): string { + const labels: Record = { + active: 'Active', + idle: 'Idle', + thinking: 'Thinking…', + error: 'Error', + offline: 'Offline', + }; + return labels[status] ?? status; + } + + getStatusChipColor(status: AgentStatus): string { + const map: Record = { + active: 'status-chip--active', + idle: 'status-chip--idle', + thinking: 'status-chip--thinking', + error: 'status-chip--error', + offline: 'status-chip--offline', + }; + return map[status] ?? ''; + } + + getLogLevelClass(level: SessionLogLine['level']): string { + return `log-line--${level}`; + } + + /** Format a date to a short time string. */ + formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } + + /** Format a date to a relative time string. */ + formatRelativeTime(date: Date): string { + const now = Date.now(); + 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`; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** Open the drawer for a specific agent. */ + open(agentData: AgentCardData): void { + this._agent = agentData; + this.isOpen.set(true); + this.loadSessionData(agentData); + } + + /** Close the drawer. */ + close(): void { + this.isOpen.set(false); + this.drawerClose.emit(); + } + + // --------------------------------------------------------------------------- + // Keyboard Handling + // --------------------------------------------------------------------------- + + @HostListener('document:keydown.escape') + onEscapeKey(): void { + if (this.isOpen()) { + this.close(); + } + } + + /** Handle keyboard navigation within the drawer. */ + onDrawerKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + this.close(); + return; + } + // Tab through actions — browser default Tab behavior is fine, + // we just trap focus within the drawer + } + + // --------------------------------------------------------------------------- + // Outside Click + // --------------------------------------------------------------------------- + + onBackdropClick(): void { + this.close(); + } + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + onOpenSession(): void { + if (this._agent) { + this.openSession.emit(this._agent.sessionKey); + } + this.close(); + } + + onPinToDashboard(): void { + if (this._agent) { + this.pinToDashboard.emit(this._agent.sessionKey); + } + // Don't close — user may want to keep viewing + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + ngOnDestroy(): void { + // Clean up any subscriptions when needed + } + + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + + /** Load mock session data for the agent (TODO: wire to real data service). */ + private loadSessionData(agentData: AgentCardData): void { + // TODO: Replace with real session data service when available. + // For now, generate placeholder log lines and messages. + const now = new Date(); + const logLines: SessionLogLine[] = []; + for (let i = 19; i >= 0; i--) { + const ts = new Date(now.getTime() - i * 5000); + const levels: SessionLogLine['level'][] = ['info', 'info', 'info', 'debug', 'warn']; + const messages = [ + `Processing task queue for ${agentData.displayName}`, + `SignalR heartbeat OK`, + `Session state: active`, + `Checking for pending commands…`, + `Updating task progress: ${Math.floor(Math.random() * 100)}%`, + ]; + logLines.push({ + timestamp: ts, + level: levels[i % levels.length], + message: messages[i % messages.length], + }); + } + this.logLines.set(logLines); + + const recentMessages: SessionMessage[] = [ + { + id: '1', + sender: 'user', + content: `Hey ${agentData.displayName}, how's the current task going?`, + timestamp: new Date(now.getTime() - 120000), + }, + { + id: '2', + sender: 'agent', + content: agentData.currentTask ?? 'Working on it — progress is steady.', + timestamp: new Date(now.getTime() - 115000), + }, + { + id: '3', + sender: 'user', + content: 'Great, let me know if you hit any blockers.', + timestamp: new Date(now.getTime() - 110000), + }, + ]; + this.recentMessages.set(recentMessages); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-session-drawer/index.ts b/frontend/src/app/components/agent-session-drawer/index.ts new file mode 100644 index 0000000..56d96ee --- /dev/null +++ b/frontend/src/app/components/agent-session-drawer/index.ts @@ -0,0 +1,2 @@ +export { AgentSessionDrawerComponent } from './agent-session-drawer.component'; +export type { SessionLogLine, SessionMessage } from './agent-session-drawer.component'; \ No newline at end of file diff --git a/frontend/src/app/components/index.ts b/frontend/src/app/components/index.ts index 730b615..84852cc 100644 --- a/frontend/src/app/components/index.ts +++ b/frontend/src/app/components/index.ts @@ -1,3 +1,5 @@ export * from './quick-jump-button/quick-jump-button.component'; export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component'; -export { QuickJumpDrawerComponent } from './quick-jump-drawer/index'; \ No newline at end of file +export { QuickJumpDrawerComponent } from './quick-jump-drawer/index'; +export { AgentSessionDrawerComponent } from './agent-session-drawer/index'; +export type { SessionLogLine, SessionMessage } from './agent-session-drawer/index'; \ No newline at end of file diff --git a/frontend/src/app/directives/index.ts b/frontend/src/app/directives/index.ts new file mode 100644 index 0000000..f01d552 --- /dev/null +++ b/frontend/src/app/directives/index.ts @@ -0,0 +1 @@ +export { LongPressDirective } from './long-press.directive'; \ No newline at end of file diff --git a/frontend/src/app/directives/long-press.directive.ts b/frontend/src/app/directives/long-press.directive.ts new file mode 100644 index 0000000..f4025b1 --- /dev/null +++ b/frontend/src/app/directives/long-press.directive.ts @@ -0,0 +1,89 @@ +import { + Directive, + ElementRef, + EventEmitter, + OnDestroy, + Output, + Input, +} from '@angular/core'; + +// ============================================================================ +// Long-Press Directive — CUB-26 +// Emits after a sustained press (500ms default). +// Used on agent cards to bypass the drawer and open Session Log directly. +// ============================================================================ + +@Directive({ + selector: '[appLongPress]', + standalone: true, + host: { + '(mousedown)': 'onMouseDown($event)', + '(mouseup)': 'onMouseUp()', + '(mouseleave)': 'onMouseLeave()', + '(touchstart)': 'onTouchStart($event)', + '(touchend)': 'onTouchEnd()', + '(touchmove)': 'onTouchMove()', + '(contextmenu)': 'onContextMenu($event)', + }, +}) +export class LongPressDirective implements OnDestroy { + /** Duration in ms before a press counts as a long press. */ + @Input() appLongPressDuration = 500; + + /** Emits when a long press is detected. Payload is the original event. */ + @Output() readonly appLongPress = new EventEmitter(); + + private timer: ReturnType | null = null; + private isLongPress = false; + + onMouseDown(event: MouseEvent): void { + this.isLongPress = false; + this.timer = setTimeout(() => { + this.isLongPress = true; + this.appLongPress.emit(event); + }, this.appLongPressDuration); + } + + onMouseUp(): void { + this.clearTimer(); + } + + onMouseLeave(): void { + this.clearTimer(); + } + + onTouchStart(event: TouchEvent): void { + this.isLongPress = false; + this.timer = setTimeout(() => { + this.isLongPress = true; + this.appLongPress.emit(event); + }, this.appLongPressDuration); + } + + onTouchEnd(): void { + this.clearTimer(); + } + + onTouchMove(): void { + // Cancel on touch move (finger moved) + this.clearTimer(); + } + + onContextMenu(event: MouseEvent): void { + // Prevent native context menu on long press + if (this.isLongPress) { + event.preventDefault(); + } + } + + ngOnDestroy(): void { + this.clearTimer(); + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/layout/layout-shell/layout-shell.component.html b/frontend/src/app/layout/layout-shell/layout-shell.component.html index e2f671d..0fbb6d9 100644 --- a/frontend/src/app/layout/layout-shell/layout-shell.component.html +++ b/frontend/src/app/layout/layout-shell/layout-shell.component.html @@ -17,4 +17,11 @@ - \ No newline at end of file + + + + \ No newline at end of file diff --git a/frontend/src/app/layout/layout-shell/layout-shell.component.ts b/frontend/src/app/layout/layout-shell/layout-shell.component.ts index 14c8e7d..5fb15ad 100644 --- a/frontend/src/app/layout/layout-shell/layout-shell.component.ts +++ b/frontend/src/app/layout/layout-shell/layout-shell.component.ts @@ -1,32 +1,75 @@ -import { ChangeDetectionStrategy, Component, HostListener, ViewChild } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, signal, ViewChild } from '@angular/core'; +import { Router, RouterOutlet } from '@angular/router'; +import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; +import { Subscription } from 'rxjs'; import { NavRailComponent } from '../nav-rail/nav-rail.component'; import { BottomNavComponent } from '../bottom-nav/bottom-nav.component'; import { HeaderBarComponent } from '../header-bar/header-bar.component'; import { QuickJumpDrawerComponent } from '../../components/quick-jump-drawer/index'; +import { AgentSessionDrawerComponent } from '../../components/agent-session-drawer/index'; +import { AgentCardData } from '../../models/agent.model'; /** * Layout Shell — wraps the main content area with adaptive navigation. * Desktop/Kiosk: Nav Rail (left) + Header + Content * Mobile: Header + Content + Bottom Nav * Per spec Section 3.1 (kiosk) and 3.2 (mobile). + * CUB-26: Hosts the Agent Session Drawer for quick-jump navigation. */ @Component({ selector: 'app-layout-shell', standalone: true, - imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent], + imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent, AgentSessionDrawerComponent], templateUrl: './layout-shell.component.html', styleUrl: './layout-shell.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class LayoutShellComponent { +export class LayoutShellComponent implements OnDestroy { @ViewChild(QuickJumpDrawerComponent) quickJumpDrawer!: QuickJumpDrawerComponent; + @ViewChild(AgentSessionDrawerComponent) sessionDrawer!: AgentSessionDrawerComponent; + + /** Whether the viewport is mobile-sized. */ + readonly isMobile = signal(false); + + private readonly breakpointSub: Subscription; + + constructor( + private readonly breakpointObserver: BreakpointObserver, + private readonly router: Router, + ) { + this.breakpointSub = this.breakpointObserver + .observe([Breakpoints.Handset, Breakpoints.Small]) + .subscribe((result) => { + this.isMobile.set(result.matches); + }); + } /** Open the quick-jump drawer from anywhere in the layout. */ openQuickJump(): void { this.quickJumpDrawer?.open(); } + /** Open the session drawer for a specific agent. */ + openSessionDrawer(agent: AgentCardData): void { + this.sessionDrawer?.open(agent); + } + + /** Open the session log page directly (long-press bypass). */ + openSessionLog(sessionKey: string): void { + this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } }); + } + + /** Handle "Open Full Session" action from session drawer. */ + onOpenSession(sessionKey: string): void { + this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } }); + } + + /** Handle "Pin to Dashboard" action from session drawer. */ + onPinToDashboard(sessionKey: string): void { + // TODO: Implement pin-to-dashboard logic + console.log('[LayoutShell] Pin to dashboard:', sessionKey); + } + /** Global keyboard shortcut: Ctrl+K or Cmd+K opens the quick-jump drawer. */ @HostListener('document:keydown', ['$event']) onGlobalKeydown(event: KeyboardEvent): void { @@ -35,4 +78,8 @@ export class LayoutShellComponent { this.quickJumpDrawer?.toggle(); } } + + ngOnDestroy(): void { + this.breakpointSub.unsubscribe(); + } } \ No newline at end of file diff --git a/frontend/src/app/pages/hub/hub-page.component.scss b/frontend/src/app/pages/hub/hub-page.component.scss index 1b2c65f..1fb6338 100644 --- a/frontend/src/app/pages/hub/hub-page.component.scss +++ b/frontend/src/app/pages/hub/hub-page.component.scss @@ -13,7 +13,16 @@ overflow-x: hidden; } -.hub-page__placeholder { +.hub-page__title { + grid-column: 1 / -1; + font-size: 24px; + font-weight: 600; + color: var(--cc-on-surface); + margin: 0 0 8px; +} + +.hub-page__placeholder, +.hub-page__empty { color: var(--cc-on-surface-variant); font-size: 16px; text-align: center; diff --git a/frontend/src/app/pages/hub/hub-page.component.ts b/frontend/src/app/pages/hub/hub-page.component.ts index 1749b94..aff9f56 100644 --- a/frontend/src/app/pages/hub/hub-page.component.ts +++ b/frontend/src/app/pages/hub/hub-page.component.ts @@ -1,15 +1,161 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AgentCardComponent } from '../../command-hub/components/agent-card/agent-card.component'; +import { AgentSessionDrawerComponent } from '../../components/agent-session-drawer/index'; +import { AgentCardData } from '../../models/agent.model'; + +// ============================================================================ +// Hub Page — Fleet status grid +// CUB-26: Integrates AgentCard click/long-press with session drawer. +// ============================================================================ @Component({ selector: 'app-hub-page', standalone: true, - imports: [], + imports: [CommonModule, AgentCardComponent, AgentSessionDrawerComponent], template: `
-

Command Hub — Fleet status grid will render here

+

Command Hub

+
+ @for (agent of agents(); track agent.id) { + + } @empty { +

No agents online

+ } +
+ + + `, styleUrl: './hub-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class HubPageComponent {} \ No newline at end of file +export class HubPageComponent { + @ViewChild(AgentSessionDrawerComponent) sessionDrawer!: AgentSessionDrawerComponent; + + readonly isMobile = signal(false); + + /** Stub agent data (TODO: wire to AgentStatusService / SignalR). */ + readonly agents = signal([ + { + id: 'otto', + displayName: 'Otto', + role: 'Orchestrator Agent', + status: 'active', + currentTask: 'Reviewing PR #42', + taskProgress: 67, + taskElapsed: '04m 12s', + sessionKey: 'agent:otto:slack:CUB-42:abc123', + channel: 'slack', + lastActivity: new Date(), + }, + { + id: 'rex', + displayName: 'Rex', + role: 'Frontend Agent', + status: 'thinking', + currentTask: 'Building agent session drawer', + taskProgress: 40, + taskElapsed: '02m 30s', + sessionKey: 'agent:rex:telegram:CUB-26:def456', + channel: 'telegram', + lastActivity: new Date(Date.now() - 30000), + }, + { + id: 'dex', + displayName: 'Dex', + role: 'Backend Agent', + status: 'idle', + currentTask: undefined, + taskProgress: undefined, + taskElapsed: undefined, + sessionKey: 'agent:dex:slack:CUB-53:ghi789', + channel: 'slack', + lastActivity: new Date(Date.now() - 300000), + }, + { + id: 'hex', + displayName: 'Hex', + role: 'Database Agent', + status: 'error', + currentTask: 'Migration failed — rollback initiated', + taskProgress: 0, + taskElapsed: '00m 45s', + sessionKey: 'agent:hex:slack:CUB-56:jkl012', + channel: 'slack', + lastActivity: new Date(Date.now() - 60000), + errorMessage: 'Connection timeout to database server', + }, + { + id: 'nano', + displayName: 'Nano', + role: 'ESP32 Agent', + status: 'offline', + currentTask: undefined, + taskProgress: undefined, + taskElapsed: undefined, + sessionKey: 'agent:nano:mqtt:CUB-48:mno345', + channel: 'mqtt', + lastActivity: new Date(Date.now() - 86400000), + }, + ]); + + constructor() { + // Detect mobile viewport + if (typeof window !== 'undefined') { + const mql = window.matchMedia('(max-width: 599px)'); + this.isMobile.set(mql.matches); + mql.addEventListener('change', (e) => this.isMobile.set(e.matches)); + } + } + + /** Card click → open session drawer with agent details. */ + onCardClick(sessionKey: string): void { + const agent = this.agents().find((a) => a.sessionKey === sessionKey); + if (agent) { + this.sessionDrawer?.open(agent); + } + } + + /** Long-press on card → bypass drawer, go directly to session log. */ + onCardLongPress(sessionKey: string): void { + console.log('[Hub] Long press — navigate to session log:', sessionKey); + // TODO: Navigate directly to session log page when sessions route is implemented + } + + /** Open full session from drawer action button. */ + onOpenSession(sessionKey: string): void { + console.log('[Hub] Open full session:', sessionKey); + // TODO: Navigate to full session view + } + + /** Pin agent to dashboard from drawer action button. */ + onPinToDashboard(sessionKey: string): void { + console.log('[Hub] Pin to dashboard:', sessionKey); + // TODO: Implement pin-to-dashboard + } + + /** Drawer closed. */ + onDrawerClose(): void { + // No-op for now — drawer is self-managing + } +} \ No newline at end of file