268 lines
8.5 KiB
TypeScript
268 lines
8.5 KiB
TypeScript
|
|
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);
|
||
|
|
}
|
||
|
|
}
|