From b4e110f4c3ea311537f62e05bbfb9b057db1ebeb Mon Sep 17 00:00:00 2001 From: "cubecraft-agents[bot]" <3458173+cubecraft-agents[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:06:24 +0000 Subject: [PATCH] CUB-51: Implement Quick-Jump Drawer Component --- frontend/src/app/components/index.ts | 1 + .../app/components/quick-jump-drawer/index.ts | 1 + .../quick-jump-drawer.component.html | 109 ++++++ .../quick-jump-drawer.component.scss | 333 ++++++++++++++++++ .../quick-jump-drawer.component.ts | 215 +++++++++++ .../header-bar/header-bar.component.html | 10 + .../layout/header-bar/header-bar.component.ts | 5 +- .../layout-shell/layout-shell.component.html | 7 +- .../layout-shell/layout-shell.component.ts | 23 +- 9 files changed, 698 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/components/index.ts create mode 100644 frontend/src/app/components/quick-jump-drawer/index.ts create mode 100644 frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.html create mode 100644 frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.scss create mode 100644 frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.ts diff --git a/frontend/src/app/components/index.ts b/frontend/src/app/components/index.ts new file mode 100644 index 0000000..bf5c601 --- /dev/null +++ b/frontend/src/app/components/index.ts @@ -0,0 +1 @@ +export { QuickJumpDrawerComponent } from './quick-jump-drawer/index'; \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-drawer/index.ts b/frontend/src/app/components/quick-jump-drawer/index.ts new file mode 100644 index 0000000..f559c65 --- /dev/null +++ b/frontend/src/app/components/quick-jump-drawer/index.ts @@ -0,0 +1 @@ +export { QuickJumpDrawerComponent } from './quick-jump-drawer.component'; \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.html b/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.html new file mode 100644 index 0000000..d9dc4d2 --- /dev/null +++ b/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.html @@ -0,0 +1,109 @@ + + + +@if (isOpen()) { +
+} + + + \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.scss b/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.scss new file mode 100644 index 0000000..1a9362c --- /dev/null +++ b/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.scss @@ -0,0 +1,333 @@ +// ============================================================================ +// Quick-Jump Drawer — Slide-out panel for fast agent switching +// Per CUB-51: slides from right, agent list with status badges, +// search/filter input, closes via ESC or outside click. +// ============================================================================ + +// --------------------------------------------------------------------------- +// Backdrop +// --------------------------------------------------------------------------- +.quick-jump-backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 998; + opacity: 0; + transition: opacity 200ms ease-out; + + &.backdrop-visible { + opacity: 1; + } +} + +// --------------------------------------------------------------------------- +// Drawer Panel +// --------------------------------------------------------------------------- +.quick-jump-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 380px; + max-width: 90vw; + 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 250ms cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3); + + &--open { + transform: translateX(0); + } +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- +.quick-jump-drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 12px; + border-bottom: 1px solid var(--cc-outline); +} + +.quick-jump-drawer__title { + font-size: 20px; + font-weight: 500; + color: var(--cc-on-surface); + margin: 0; + letter-spacing: -0.01em; +} + +.quick-jump-drawer__close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--cc-on-surface-variant); + font-size: 18px; + cursor: pointer; + transition: background-color 150ms ease, color 150ms ease; + + &:hover { + background-color: var(--cc-surface-container-high); + color: var(--cc-on-surface); + } + + &:focus-visible { + outline: 2px solid var(--status-active); + outline-offset: 2px; + } +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- +.quick-jump-drawer__search { + position: relative; + display: flex; + align-items: center; + margin: 16px 24px 8px; + border: 1px solid var(--cc-outline); + border-radius: 12px; + background-color: var(--cc-surface-container-high); + transition: border-color 150ms ease; + + &:focus-within { + border-color: var(--status-active); + } +} + +.quick-jump-drawer__search-icon { + display: flex; + align-items: center; + justify-content: center; + padding-left: 12px; + font-family: 'Material Icons'; + font-size: 20px; + color: var(--cc-on-surface-variant); + pointer-events: none; + user-select: none; + + // Use a simple "search" text since icon font may not be loaded inside + // the drawer — rely on Material icon font from the parent app + &::before { + content: 'search'; + font-family: 'Material Icons'; + } +} + +.quick-jump-drawer__search-input { + flex: 1; + border: none; + outline: none; + background: transparent; + padding: 12px 8px; + font-size: 15px; + font-family: 'Inter', 'Roboto', sans-serif; + color: var(--cc-on-surface); + + &::placeholder { + color: var(--cc-on-surface-variant); + opacity: 0.7; + } +} + +.quick-jump-drawer__search-clear { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin-right: 4px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--cc-on-surface-variant); + font-size: 14px; + cursor: pointer; + transition: background-color 150ms ease, color 150ms ease; + + &:hover { + background-color: var(--cc-surface-container); + color: var(--cc-on-surface); + } + + &:focus-visible { + outline: 2px solid var(--status-active); + outline-offset: 2px; + } +} + +// --------------------------------------------------------------------------- +// Agent List +// --------------------------------------------------------------------------- +.quick-jump-drawer__agent-list { + list-style: none; + margin: 0; + padding: 8px 12px; + overflow-y: auto; + flex: 1; +} + +.quick-jump-drawer__agent-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 12px; + cursor: pointer; + transition: background-color 150ms ease; + + &:hover, + &--highlighted { + background-color: var(--cc-surface-container-high); + } + + &--highlighted { + outline: 2px solid var(--status-active); + outline-offset: -2px; + } + + &:focus-visible { + outline: 2px solid var(--status-active); + outline-offset: 2px; + } +} + +.quick-jump-drawer__agent-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; // Allow text truncation + flex: 1; +} + +.quick-jump-drawer__agent-name { + font-size: 15px; + font-weight: 500; + color: var(--cc-on-surface); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.quick-jump-drawer__agent-role { + font-size: 12px; + color: var(--cc-on-surface-variant); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.quick-jump-drawer__agent-status-label { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 3px 8px; + border-radius: 6px; + white-space: nowrap; + + &.status-label--active { + color: var(--status-active); + background-color: var(--status-active-bg); + } + + &.status-label--idle { + color: var(--status-idle); + background-color: var(--status-idle-bg); + } + + &.status-label--thinking { + color: var(--status-thinking); + background-color: var(--status-thinking-bg); + } + + &.status-label--error { + color: var(--status-error); + background-color: var(--status-error-bg); + } + + &.status-label--offline { + color: var(--status-offline); + background-color: rgba(100, 116, 139, 0.12); + } +} + +// --------------------------------------------------------------------------- +// Empty State +// --------------------------------------------------------------------------- +.quick-jump-drawer__empty { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 24px; + color: var(--cc-on-surface-variant); + font-size: 14px; + text-align: center; +} + +// --------------------------------------------------------------------------- +// Footer +// --------------------------------------------------------------------------- +.quick-jump-drawer__footer { + padding: 12px 24px 16px; + border-top: 1px solid var(--cc-outline); +} + +.quick-jump-drawer__footer-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 11px; + color: var(--cc-on-surface-variant); + opacity: 0.7; + + kbd { + display: inline-block; + padding: 2px 6px; + font-size: 11px; + font-family: var(--cc-font-mono); + background-color: var(--cc-surface-container-high); + border: 1px solid var(--cc-outline); + border-radius: 4px; + color: var(--cc-on-surface-variant); + line-height: 1.4; + } +} + +// --------------------------------------------------------------------------- +// Mobile Adjustments +// --------------------------------------------------------------------------- +@media (max-width: 599px) { + .quick-jump-drawer { + width: 100%; + max-width: 100vw; + } + + .quick-jump-drawer__header { + padding: 16px 16px 10px; + } + + .quick-jump-drawer__search { + margin: 12px 16px 8px; + } + + .quick-jump-drawer__agent-list { + padding: 4px 8px; + } + + .quick-jump-drawer__footer { + padding: 10px 16px 14px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.ts b/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.ts new file mode 100644 index 0000000..983348f --- /dev/null +++ b/frontend/src/app/components/quick-jump-drawer/quick-jump-drawer.component.ts @@ -0,0 +1,215 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + HostListener, + OnDestroy, + Output, + signal, + ViewChild, +} from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { Subject, takeUntil } from 'rxjs'; +import { AgentCardData } from '../../models/agent.model'; +import { AgentStatusService } from '../../services/agent-status.service'; + +@Component({ + selector: 'app-quick-jump-drawer', + standalone: true, + imports: [ReactiveFormsModule], + templateUrl: './quick-jump-drawer.component.html', + styleUrl: './quick-jump-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuickJumpDrawerComponent implements OnDestroy { + /** Emits when the drawer should close (ESC, outside click, or item select). */ + @Output() readonly drawerClose = new EventEmitter(); + + /** Whether the drawer is visible. */ + readonly isOpen = signal(false); + + /** Search/filter input control. */ + readonly searchControl = new FormControl('', { nonNullable: true }); + + /** Filtered agent list based on search. */ + readonly filteredAgents = signal([]); + + /** Track which agent row is highlighted via keyboard navigation. */ + readonly highlightedIndex = signal(-1); + + @ViewChild('searchInput') searchInput!: ElementRef; + @ViewChild('drawerPanel') drawerPanel!: ElementRef; + + private readonly destroy$ = new Subject(); + + constructor(private readonly agentStatusService: AgentStatusService) { + // Reactively filter agents as the search input changes + this.searchControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((query) => this.filterAgents(query)); + + // Initial load + this.filterAgents(''); + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** Open the drawer and focus the search input. */ + open(): void { + this.isOpen.set(true); + this.searchControl.setValue('', { emitEvent: false }); + this.highlightedIndex.set(-1); + // Focus search input after animation frame (drawer needs to render first) + requestAnimationFrame(() => { + this.searchInput?.nativeElement?.focus(); + }); + } + + /** Close the drawer. */ + close(): void { + this.isOpen.set(false); + this.searchControl.setValue('', { emitEvent: false }); + this.highlightedIndex.set(-1); + this.drawerClose.emit(); + } + + /** Toggle the drawer open/close. */ + toggle(): void { + if (this.isOpen()) { + this.close(); + } else { + this.open(); + } + } + + // --------------------------------------------------------------------------- + // Keyboard Handling + // --------------------------------------------------------------------------- + + @HostListener('document:keydown.escape') + onEscapeKey(): void { + if (this.isOpen()) { + this.close(); + } + } + + /** Handle keyboard navigation within the drawer panel. */ + onDrawerKeydown(event: KeyboardEvent): void { + const agents = this.filteredAgents(); + if (!agents.length) return; + + switch (event.key) { + case 'ArrowDown': { + event.preventDefault(); + this.highlightedIndex.update((i) => + i < agents.length - 1 ? i + 1 : 0 + ); + this.scrollIntoView(); + break; + } + case 'ArrowUp': { + event.preventDefault(); + this.highlightedIndex.update((i) => + i > 0 ? i - 1 : agents.length - 1 + ); + this.scrollIntoView(); + break; + } + case 'Enter': { + const idx = this.highlightedIndex(); + if (idx >= 0 && idx < agents.length) { + this.selectAgent(agents[idx]); + } + break; + } + } + } + + // --------------------------------------------------------------------------- + // Outside Click + // --------------------------------------------------------------------------- + + /** Close when clicking on the backdrop (outside the panel). */ + onBackdropClick(event: MouseEvent): void { + if ( + this.drawerPanel?.nativeElement && + !this.drawerPanel.nativeElement.contains(event.target as Node) + ) { + this.close(); + } + } + + // --------------------------------------------------------------------------- + // Agent Selection + // --------------------------------------------------------------------------- + + /** Select an agent — navigates or focuses the agent card. */ + selectAgent(agent: AgentCardData): void { + // TODO: Wire up navigation to the selected agent's detail view + // For now, emit close after selection + console.log('[QuickJump] Selected agent:', agent.id); + this.close(); + } + + // --------------------------------------------------------------------------- + // Status Helpers + // --------------------------------------------------------------------------- + + /** Get the CSS class for a given agent status. */ + getStatusClass(status: string): string { + return `status-dot--${status}`; + } + + /** Get a human-readable label for an agent status. */ + getStatusLabel(status: string): string { + const labels: Record = { + active: 'Active', + idle: 'Idle', + thinking: 'Thinking', + error: 'Error', + offline: 'Offline', + }; + return labels[status] ?? status; + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + + private filterAgents(query: string): void { + const allAgents = this.agentStatusService.agents(); + const lowerQuery = query.toLowerCase().trim(); + + if (!lowerQuery) { + this.filteredAgents.set(allAgents); + return; + } + + const filtered = allAgents.filter( + (agent) => + agent.displayName.toLowerCase().includes(lowerQuery) || + agent.id.toLowerCase().includes(lowerQuery) || + agent.role.toLowerCase().includes(lowerQuery) + ); + this.filteredAgents.set(filtered); + this.highlightedIndex.set(-1); + } + + private scrollIntoView(): void { + const idx = this.highlightedIndex(); + const el = document.getElementById(`quick-jump-agent-${idx}`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } +} \ No newline at end of file diff --git a/frontend/src/app/layout/header-bar/header-bar.component.html b/frontend/src/app/layout/header-bar/header-bar.component.html index c9c6b9c..fd04dcd 100644 --- a/frontend/src/app/layout/header-bar/header-bar.component.html +++ b/frontend/src/app/layout/header-bar/header-bar.component.html @@ -2,6 +2,16 @@

Command Hub

+ + +
+ + + \ 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 cb9f470..14c8e7d 100644 --- a/frontend/src/app/layout/layout-shell/layout-shell.component.ts +++ b/frontend/src/app/layout/layout-shell/layout-shell.component.ts @@ -1,8 +1,9 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostListener, ViewChild } from '@angular/core'; import { RouterOutlet } from '@angular/router'; 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'; /** * Layout Shell — wraps the main content area with adaptive navigation. @@ -13,9 +14,25 @@ import { HeaderBarComponent } from '../header-bar/header-bar.component'; @Component({ selector: 'app-layout-shell', standalone: true, - imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent], + imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent], templateUrl: './layout-shell.component.html', styleUrl: './layout-shell.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class LayoutShellComponent {} \ No newline at end of file +export class LayoutShellComponent { + @ViewChild(QuickJumpDrawerComponent) quickJumpDrawer!: QuickJumpDrawerComponent; + + /** Open the quick-jump drawer from anywhere in the layout. */ + openQuickJump(): void { + this.quickJumpDrawer?.open(); + } + + /** Global keyboard shortcut: Ctrl+K or Cmd+K opens the quick-jump drawer. */ + @HostListener('document:keydown', ['$event']) + onGlobalKeydown(event: KeyboardEvent): void { + if ((event.ctrlKey || event.metaKey) && event.key === 'k') { + event.preventDefault(); + this.quickJumpDrawer?.toggle(); + } + } +} \ No newline at end of file