From ec01e5bbcb3937e3e2d7f7fd27c9d7c0a7d02810 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 16:44:45 -0700 Subject: [PATCH] feat(chat,examples-chat): sidenav collapse toggle + remove leftover hamburger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the leftover floating "☰" hamburger (vestige of when the sidenav was a palette-toggled drawer) with proper collapse/expand controls inside the sidenav itself. - New chevron toggle button in the sidenav header — flips between expanded (280px) and collapsed (56px icon-strip) modes. - Cmd+B / Ctrl+B keyboard shortcut (VS Code convention) toggles the same. - Demo-shell stores the chosen desktop mode in localStorage so the preference persists across reloads. - Collapsed-mode CSS was already fully wired (icon-strip width, label hiding) — this commit just adds the entry point. - Mobile (<1024px) still uses drawer mode with a small floating hamburger as the open trigger. The hamburger renders only when the drawer is closed and the viewport is narrow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../content/docs/chat/api/api-docs.json | 12 ++++ .../src/app/shell/demo-shell.component.css | 9 ++- .../src/app/shell/demo-shell.component.html | 19 +++--- .../src/app/shell/demo-shell.component.ts | 19 +++++- .../app/shell/palette-persistence.service.ts | 1 + .../chat-sidenav.component.spec.ts | 61 +++++++++++++++++++ .../chat-sidenav/chat-sidenav.component.ts | 40 +++++++++++- 7 files changed, 146 insertions(+), 15 deletions(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 0cfc4029..0d50687a 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3412,6 +3412,12 @@ "description": "", "optional": false }, + { + "name": "modeChange", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "newChat", "type": "OutputEmitterRef", @@ -3450,6 +3456,12 @@ } ], "methods": [ + { + "name": "onCollapseToggle", + "signature": "onCollapseToggle()", + "description": "", + "params": [] + }, { "name": "onEscape", "signature": "onEscape()", diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.css b/examples/chat/angular/src/app/shell/demo-shell.component.css index 843606d8..7e4b9b97 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.css +++ b/examples/chat/angular/src/app/shell/demo-shell.component.css @@ -38,11 +38,14 @@ transition: padding-left 200ms ease; padding-left: 0; } -.demo-shell__main--push { - padding-left: 280px; +.demo-shell__main[data-sidenav-mode="expanded"] { + padding-left: var(--ngaf-chat-sidenav-width-expanded, 280px); +} +.demo-shell__main[data-sidenav-mode="collapsed"] { + padding-left: var(--ngaf-chat-sidenav-width-collapsed, 56px); } @media (max-width: 1023px) { - .demo-shell__main--push { padding-left: 0; } + .demo-shell__main[data-sidenav-mode] { padding-left: 0; } } .demo-shell__interrupt-panel { 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 4a90a11d..344faf9f 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -1,11 +1,13 @@
- + @if (sidenavMode() === 'drawer' && !drawerOpen()) { + + }
@if (agent.interrupt && agent.interrupt()) { diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index a6364291..9d29f100 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -167,9 +167,17 @@ export class DemoShell { typeof window !== 'undefined' ? window.innerWidth : 1440, ); - /** Computed sidenav mode based on viewport width. */ + /** + * User's chosen desktop sidenav mode. Persisted across reloads. + * Below 1024px the shell ignores this and forces drawer mode. + */ + private readonly storedDesktopMode = signal<'expanded' | 'collapsed'>( + (this.persistence.read('sidenavMode') as 'expanded' | 'collapsed' | null) ?? 'expanded', + ); + + /** Computed sidenav mode: viewport forces drawer below 1024px, else user preference. */ protected readonly sidenavMode = computed(() => - this.viewportWidth() >= 1024 ? 'expanded' : 'drawer', + this.viewportWidth() >= 1024 ? this.storedDesktopMode() : 'drawer', ); /** Client-side title filter over the loaded threads. */ @@ -312,6 +320,13 @@ export class DemoShell { this.onSidenavOpenChange(!this.drawerOpen()); } + protected onSidenavModeChange(next: ChatSidenavMode): void { + // Drawer is viewport-driven; ignore user attempts to set it directly. + if (next === 'drawer') return; + this.storedDesktopMode.set(next); + this.persistence.write('sidenavMode', next); + } + onTimelineReplay(checkpointId: string): void { void this.agent.submit(null as never, { checkpointId } as never); } diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.ts index c40ed330..1e798d2e 100644 --- a/examples/chat/angular/src/app/shell/palette-persistence.service.ts +++ b/examples/chat/angular/src/app/shell/palette-persistence.service.ts @@ -10,6 +10,7 @@ interface PaletteState { theme?: string | null; threadId?: string | null; drawerOpen?: boolean | null; + sidenavMode?: 'expanded' | 'collapsed' | null; } type PaletteKey = keyof PaletteState; diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts index 93754b04..9e53dc6b 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts @@ -120,6 +120,67 @@ describe('ChatSidenavComponent', () => { expect(lists[1].getAttribute('mode')).toBe('archived'); }); + it('renders the collapse chevron in expanded mode with "Collapse sidenav" label', () => { + const fixture = render({ mode: 'expanded' }); + const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement; + expect(btn).not.toBeNull(); + expect(btn.getAttribute('aria-label')).toBe('Collapse sidenav'); + }); + + it('renders the expand chevron in collapsed mode with "Expand sidenav" label', () => { + const fixture = render({ mode: 'collapsed' }); + const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement; + expect(btn).not.toBeNull(); + expect(btn.getAttribute('aria-label')).toBe('Expand sidenav'); + }); + + it('omits the collapse chevron in drawer mode', () => { + const fixture = render({ mode: 'drawer' }); + expect(fixture.nativeElement.querySelector('.chat-sidenav__action--collapse')).toBeNull(); + }); + + it('clicking the chevron in expanded mode emits modeChange="collapsed"', () => { + const fixture = render({ mode: 'expanded' }); + let last: string | undefined; + fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); + const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement; + btn.click(); + expect(last).toBe('collapsed'); + }); + + it('clicking the chevron in collapsed mode emits modeChange="expanded"', () => { + const fixture = render({ mode: 'collapsed' }); + let last: string | undefined; + fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); + const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--collapse') as HTMLButtonElement; + btn.click(); + expect(last).toBe('expanded'); + }); + + it('Cmd+B in expanded mode emits modeChange="collapsed"', () => { + const fixture = render({ mode: 'expanded' }); + let last: string | undefined; + fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true })); + expect(last).toBe('collapsed'); + }); + + it('Cmd+B in collapsed mode emits modeChange="expanded"', () => { + const fixture = render({ mode: 'collapsed' }); + let last: string | undefined; + fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true })); + expect(last).toBe('expanded'); + }); + + it('Cmd+B is a no-op in drawer mode', () => { + const fixture = render({ mode: 'drawer' }); + let emits = 0; + fixture.componentInstance.modeChange.subscribe(() => { emits++; }); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true })); + expect(emits).toBe(0); + }); + it('clicking the archived heading toggles aria-expanded', () => { const fixture = render({ threads: [{ id: 't1' }] }); fixture.componentRef.setInput('archivedThreads', []); diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts index e810dc21..d439e14d 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts @@ -77,6 +77,26 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer'; Search + @if (mode() !== 'drawer') { + + }
@@ -152,6 +172,7 @@ export class ChatSidenavComponent { readonly threadSelected = output(); readonly searchOpened = output(); readonly openChange = output(); + readonly modeChange = output(); protected readonly archivedOpen = signal(false); @@ -161,14 +182,23 @@ export class ChatSidenavComponent { fromEvent(window, 'keydown') .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((e) => { - if (!((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k')) return; + if (!(e.metaKey || e.ctrlKey)) return; + const key = e.key.toLowerCase(); + if (key !== 'k' && key !== 'b') return; const t = e.target as HTMLElement | null; if (t) { const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable) return; } + if (key === 'k') { + e.preventDefault(); + this.searchOpened.emit(); + return; + } + // Cmd/Ctrl+B: toggle expanded ↔ collapsed (no-op in drawer mode). + if (this.mode() === 'drawer') return; e.preventDefault(); - this.searchOpened.emit(); + this.modeChange.emit(this.mode() === 'collapsed' ? 'expanded' : 'collapsed'); }); } @@ -177,4 +207,10 @@ export class ChatSidenavComponent { this.openChange.emit(false); } } + + protected onCollapseToggle(): void { + const m = this.mode(); + if (m === 'drawer') return; + this.modeChange.emit(m === 'collapsed' ? 'expanded' : 'collapsed'); + } }