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()) {
+
+}
+
+
+
+
+
+
+
+
+ search
+
+ @if (searchControl.value) {
+
+ }
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
\ 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