Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3412,6 +3412,12 @@
"description": "",
"optional": false
},
{
"name": "modeChange",
"type": "OutputEmitterRef<ChatSidenavMode>",
"description": "",
"optional": false
},
{
"name": "newChat",
"type": "OutputEmitterRef<void>",
Expand Down Expand Up @@ -3450,6 +3456,12 @@
}
],
"methods": [
{
"name": "onCollapseToggle",
"signature": "onCollapseToggle()",
"description": "",
"params": []
},
{
"name": "onEscape",
"signature": "onEscape()",
Expand Down
9 changes: 6 additions & 3 deletions examples/chat/angular/src/app/shell/demo-shell.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 11 additions & 8 deletions examples/chat/angular/src/app/shell/demo-shell.component.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<div class="demo-shell">
<button
type="button"
class="demo-shell__hamburger"
aria-label="Open conversations"
[attr.aria-expanded]="drawerOpen()"
(click)="toggleSidenav()"
>☰</button>
@if (sidenavMode() === 'drawer' && !drawerOpen()) {
<button
type="button"
class="demo-shell__hamburger"
aria-label="Open conversations"
[attr.aria-expanded]="drawerOpen()"
(click)="toggleSidenav()"
>☰</button>
}

<chat-sidenav
[threads]="threadsSvc.threads()"
Expand All @@ -18,11 +20,12 @@
(threadSelected)="onThreadSelected($event)"
(searchOpened)="paletteOpen.set(true)"
(openChange)="onSidenavOpenChange($event)"
(modeChange)="onSidenavModeChange($event)"
/>

<div
class="demo-shell__main"
[class.demo-shell__main--push]="drawerOpen() && sidenavMode() === 'expanded'"
[attr.data-sidenav-mode]="sidenavMode() !== 'drawer' ? sidenavMode() : null"
>
<router-outlet />
@if (agent.interrupt && agent.interrupt()) {
Expand Down
19 changes: 17 additions & 2 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatSidenavMode>(() =>
this.viewportWidth() >= 1024 ? 'expanded' : 'drawer',
this.viewportWidth() >= 1024 ? this.storedDesktopMode() : 'drawer',
);

/** Client-side title filter over the loaded threads. */
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface PaletteState {
theme?: string | null;
threadId?: string | null;
drawerOpen?: boolean | null;
sidenavMode?: 'expanded' | 'collapsed' | null;
}

type PaletteKey = keyof PaletteState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ export type ChatSidenavMode = 'expanded' | 'collapsed' | 'drawer';
</svg>
<span class="chat-sidenav__action-label">Search</span>
</button>
@if (mode() !== 'drawer') {
<button
type="button"
class="chat-sidenav__action chat-sidenav__action--collapse"
(click)="onCollapseToggle()"
[attr.aria-label]="mode() === 'collapsed' ? 'Expand sidenav' : 'Collapse sidenav'"
[attr.title]="(mode() === 'collapsed' ? 'Expand sidenav' : 'Collapse sidenav') + ' (⌘B)'"
>
@if (mode() === 'collapsed') {
<svg class="chat-sidenav__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 6 15 12 9 18"/>
</svg>
} @else {
<svg class="chat-sidenav__action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 6 9 12 15 18"/>
</svg>
}
<span class="chat-sidenav__action-label">{{ mode() === 'collapsed' ? 'Expand' : 'Collapse' }}</span>
</button>
}
</div>

<div class="chat-sidenav__primary">
Expand Down Expand Up @@ -152,6 +172,7 @@ export class ChatSidenavComponent {
readonly threadSelected = output<string>();
readonly searchOpened = output<void>();
readonly openChange = output<boolean>();
readonly modeChange = output<ChatSidenavMode>();

protected readonly archivedOpen = signal<boolean>(false);

Expand All @@ -161,14 +182,23 @@ export class ChatSidenavComponent {
fromEvent<KeyboardEvent>(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');
});
}

Expand All @@ -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');
}
}
Loading