diff --git a/examples/chat/angular/src/app/shell/control-palette.component.css b/examples/chat/angular/src/app/shell/control-palette.component.css index 472c55dd9..91cc00bd1 100644 --- a/examples/chat/angular/src/app/shell/control-palette.component.css +++ b/examples/chat/angular/src/app/shell/control-palette.component.css @@ -5,110 +5,258 @@ z-index: 1000; } -.palette { - display: flex; - flex-direction: column; +/* ── Status pill (collapsed) ─────────────────────────────────────────── */ + +.palette-pill { + display: inline-flex; + align-items: center; gap: 8px; - background: #1a1d23; - color: #e6e9ef; - border: 1px solid #303540; - border-radius: 10px; - padding: 10px; + background: #18181b; + color: #fafafa; + border: 1px solid #27272a; + border-radius: 999px; + padding: 6px 12px; + font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3); - min-width: 220px; + cursor: pointer; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); + transition: background 120ms ease, border-color 120ms ease; +} +.palette-pill:hover { + background: #1f1f23; + border-color: #3f3f46; } -.palette--collapsed { - width: 36px; - height: 36px; +.palette-pill__dot { + width: 8px; + height: 8px; border-radius: 50%; - background: #1a1d23; - color: #e6e9ef; - border: 1px solid #303540; - cursor: pointer; - font-size: 16px; + background: #4ade80; + box-shadow: 0 0 8px rgba(74, 222, 128, 0.6); +} +.palette-pill__dot--streaming { + background: #4f8df5; + box-shadow: 0 0 8px rgba(79, 141, 245, 0.7); + animation: palette-pill-pulse 1.2s ease-in-out infinite; } +@keyframes palette-pill-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +.palette-pill__sep, +.palette-pill__mode { + color: #a1a1aa; +} +.palette-pill__model { + font-weight: 600; +} + +/* ── Panel (expanded) ────────────────────────────────────────────────── */ -.palette__group { +.palette-panel { + width: 320px; + background: #18181b; + border: 1px solid #27272a; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + color: #fafafa; + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + font-size: 13px; + overflow: hidden; + transform-origin: top right; + animation: palette-panel-enter 120ms ease; +} +@keyframes palette-panel-enter { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} + +.palette-panel__header { display: flex; align-items: center; - gap: 6px; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid #27272a; } - -.palette__group--mode { - background: #0f1116; +.palette-panel__title { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; +} +.palette-panel__close { + background: transparent; + border: 0; + color: #71717a; + cursor: pointer; + width: 24px; + height: 24px; border-radius: 6px; + font-size: 16px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} +.palette-panel__close:hover { background: #27272a; color: #fafafa; } + +.palette-panel__divider { + height: 1px; + background: #27272a; +} + +.palette-panel__section { + padding: 14px 16px; +} +.palette-panel__section--action { + padding-top: 14px; + padding-bottom: 16px; +} +.palette-panel__section-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #71717a; + margin: 0 0 10px 0; +} + +/* ── Segmented (Mode) ────────────────────────────────────────────────── */ + +.palette-segmented { + display: flex; + background: #09090b; + border: 1px solid #27272a; + border-radius: 8px; padding: 3px; + gap: 0; } -.palette__group--mode button { +.palette-segmented button { flex: 1; background: transparent; border: 0; - color: inherit; - padding: 5px 8px; - border-radius: 4px; + color: #a1a1aa; + padding: 6px 8px; + border-radius: 5px; font-size: 12px; cursor: pointer; + font-family: inherit; + transition: background 120ms ease, color 120ms ease; } -.palette__group--mode button.is-active { - background: #2c313c; +.palette-segmented button:hover { background: #18181b; color: #fafafa; } +.palette-segmented button.is-active { + background: #27272a; + color: #fafafa; + font-weight: 500; } -.palette__group--model { - display: grid; - grid-template-columns: auto 1fr; +/* ── Field rows (label left, control right) ──────────────────────────── */ + +.palette-row { + display: flex; align-items: center; + justify-content: space-between; + gap: 12px; } -.palette__label { - opacity: 0.7; - margin-right: 8px; -} -.palette__group--model select { - background: #0f1116; - color: inherit; - border: 1px solid #303540; - border-radius: 4px; - padding: 4px 6px; +.palette-row + .palette-row { margin-top: 10px; } +.palette-row__label { + font-size: 13px; + color: #d4d4d8; } -.palette__toggle { - display: flex; +/* ── Styled select (native - @for (opt of modelOptions(); track opt.value) { - - } - - +
+

Mode

+
+ + + +
+
- +
- +
+

Model

+
+ +
+ {{ labelFor(model(), modelOptions()) }} + + +
+
+
+ +
+ {{ labelFor(effort(), effortOptions()) }} + + +
+
+
+ +
+ {{ labelFor(genUiMode(), genUiOptions()) }} + + +
+
+
- +
- +
+

Appearance

+
+ +
+ {{ labelFor(theme(), themeOptions()) }} + + +
+
+
+ Debug overlay + +
+
- +
- +
+ +
} diff --git a/examples/chat/angular/src/app/shell/control-palette.component.spec.ts b/examples/chat/angular/src/app/shell/control-palette.component.spec.ts new file mode 100644 index 000000000..5c907fdc9 --- /dev/null +++ b/examples/chat/angular/src/app/shell/control-palette.component.spec.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ControlPalette } from './control-palette.component'; +import { PalettePersistence } from './palette-persistence.service'; + +@Component({ + standalone: true, + imports: [ControlPalette], + template: ``, +}) +class HostComponent { + debug = signal(false); + streaming = signal(false); +} + +class StubPersistence { + private store: Record = {}; + read(key: string): T | undefined { return this.store[key] as T | undefined; } + write(key: string, value: T): void { this.store[key] = value; } +} + +describe('ControlPalette — pill / panel toggle', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [{ provide: PalettePersistence, useClass: StubPersistence }], + }); + }); + + it('starts in the pill state (collapsed) on first run', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + expect(fx.nativeElement.querySelector('.palette-panel')).toBeNull(); + }); + + it('clicking the pill opens the panel', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-panel')).toBeTruthy(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeNull(); + }); + + it('clicking the close button collapses the panel back to a pill', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-panel__close') as HTMLButtonElement).click(); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + }); + + it('Escape keydown closes the panel', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + }); + + it('document click outside the palette closes it', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + + const outsideTarget = document.createElement('div'); + document.body.appendChild(outsideTarget); + outsideTarget.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fx.detectChanges(); + document.body.removeChild(outsideTarget); + + expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); + }); + + it('document click INSIDE the panel does not close it', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + + const title = fx.nativeElement.querySelector('.palette-panel__title') as HTMLElement; + title.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fx.detectChanges(); + + expect(fx.nativeElement.querySelector('.palette-panel')).toBeTruthy(); + }); + + it('streaming=true adds the streaming class on the dot when pill is visible', () => { + const fx = TestBed.createComponent(HostComponent); + fx.componentInstance.streaming.set(true); + fx.detectChanges(); + expect( + fx.nativeElement.querySelector('.palette-pill__dot--streaming'), + ).toBeTruthy(); + }); + + it('debug switch toggles aria-checked via output', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); + fx.detectChanges(); + const sw = fx.nativeElement.querySelector('.palette-switch') as HTMLButtonElement; + expect(sw.getAttribute('aria-checked')).toBe('false'); + sw.click(); + fx.detectChanges(); + expect(sw.getAttribute('aria-checked')).toBe('true'); + }); +}); diff --git a/examples/chat/angular/src/app/shell/control-palette.component.ts b/examples/chat/angular/src/app/shell/control-palette.component.ts index ffdc18382..60537dd1c 100644 --- a/examples/chat/angular/src/app/shell/control-palette.component.ts +++ b/examples/chat/angular/src/app/shell/control-palette.component.ts @@ -7,6 +7,8 @@ import { signal, inject, effect, + ElementRef, + HostListener, } from '@angular/core'; import { PalettePersistence } from './palette-persistence.service'; import type { DemoMode } from './demo-shell.component'; @@ -20,6 +22,7 @@ import type { DemoMode } from './demo-shell.component'; }) export class ControlPalette { private readonly persistence = inject(PalettePersistence); + private readonly elementRef = inject>(ElementRef); readonly mode = input.required(); readonly model = input.required(); @@ -31,6 +34,8 @@ export class ControlPalette { readonly theme = input.required(); readonly themeOptions = input.required(); readonly debugOpen = input.required(); + /** True while the agent is streaming. Drives the status-dot pulse. */ + readonly streaming = input(false); readonly modeChange = output(); readonly modelChange = output(); @@ -40,7 +45,12 @@ export class ControlPalette { readonly debugOpenChange = output(); readonly newConversation = output(); - protected readonly collapsed = signal(this.persistence.read('collapsed') ?? false); + /** + * Whether the palette is collapsed to its status-pill state. Defaults + * to true (pill = resting state, matching Next.js dev tools). Persisted + * across reloads via PalettePersistence. + */ + protected readonly collapsed = signal(this.persistence.read('collapsed') ?? true); constructor() { effect(() => { @@ -48,8 +58,12 @@ export class ControlPalette { }); } - protected toggleCollapsed(): void { - this.collapsed.update((c) => !c); + protected expand(): void { + this.collapsed.set(false); + } + + protected close(): void { + this.collapsed.set(true); } protected pickMode(next: DemoMode): void { @@ -83,4 +97,36 @@ export class ControlPalette { protected emitNewConversation(): void { this.newConversation.emit(); } + + /** + * Close the panel on document-level clicks outside the palette. + * No-ops when already collapsed; checks event.target containment so + * inside-panel clicks don't close. + */ + @HostListener('document:click', ['$event']) + protected onDocumentClick(event: MouseEvent): void { + if (this.collapsed()) return; + const target = event.target as Node | null; + if (target && this.elementRef.nativeElement.contains(target)) return; + this.close(); + } + + /** Close on Escape anywhere in the document while the panel is open. */ + @HostListener('document:keydown.escape') + protected onEscape(): void { + if (!this.collapsed()) this.close(); + } + + /** + * Selected-option label for a value across an options list. Used by the + * styled select trigger to show the human-friendly label rather than + * the raw value. + */ + protected labelFor( + value: string, + options: readonly { value: string; label: string }[], + ): string { + const match = options.find(o => o.value === value); + return match?.label ?? value; + } } diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.html b/examples/chat/angular/src/app/shell/demo-shell.component.html index 0cb6be6e5..a33c8ef95 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -18,6 +18,7 @@ [theme]="theme()" [themeOptions]="themeOptions()" [debugOpen]="debugOpen()" + [streaming]="agent.status() === 'running'" (modeChange)="onModeChange($event)" (modelChange)="onModelChange($event)" (effortChange)="onEffortChange($event)"