CUB-26: Quick-jump drawer and modal components
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m5s
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m5s
This commit is contained in:
@@ -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<string>();
|
||||
|
||||
/** Emitted when the user clicks "Pin to Dashboard". Payload is the session key. */
|
||||
@Output() readonly pinToDashboard = new EventEmitter<string>();
|
||||
|
||||
/** Emitted when the drawer closes. */
|
||||
@Output() readonly drawerClose = new EventEmitter<void>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readonly isOpen = signal(false);
|
||||
readonly logLines = signal<SessionLogLine[]>([]);
|
||||
readonly recentMessages = signal<SessionMessage[]>([]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View Children
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getStatusClass(status: string): string {
|
||||
return `status-dot--${status}`;
|
||||
}
|
||||
|
||||
getStatusLabel(status: AgentStatus): string {
|
||||
const labels: Record<AgentStatus, string> = {
|
||||
active: 'Active',
|
||||
idle: 'Idle',
|
||||
thinking: 'Thinking…',
|
||||
error: 'Error',
|
||||
offline: 'Offline',
|
||||
};
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
getStatusChipColor(status: AgentStatus): string {
|
||||
const map: Record<AgentStatus, string> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user