(this.persistence.read('collapsed') ?? false);
@@ -86,6 +88,10 @@ export class ControlPalette {
this.timelineOpenChange.emit(!this.timelineOpen());
}
+ protected toggleThreads(): void {
+ this.threadsOpenChange.emit(!this.threadsOpen());
+ }
+
protected emitNewConversation(): void {
this.newConversation.emit();
}
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 b4438e200..2d510ab71 100644
--- a/examples/chat/angular/src/app/shell/demo-shell.component.css
+++ b/examples/chat/angular/src/app/shell/demo-shell.component.css
@@ -60,3 +60,19 @@
z-index: 996;
padding: 8px 0;
}
+
+.demo-shell__threads-panel {
+ position: fixed;
+ left: 0;
+ top: 80px;
+ bottom: 96px;
+ width: 240px;
+ padding: 12px 8px;
+ background: #1a1d23;
+ border-right: 1px solid #303540;
+ overflow-y: auto;
+ z-index: 996;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
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 d64934941..452aa3456 100644
--- a/examples/chat/angular/src/app/shell/demo-shell.component.html
+++ b/examples/chat/angular/src/app/shell/demo-shell.component.html
@@ -13,6 +13,7 @@
[themeOptions]="themeOptions()"
[debugOpen]="debugOpen()"
[timelineOpen]="timelineOpen()"
+ [threadsOpen]="threadsOpen()"
(modeChange)="onModeChange($event)"
(modelChange)="onModelChange($event)"
(effortChange)="onEffortChange($event)"
@@ -20,9 +21,22 @@
(themeChange)="onThemeChange($event)"
(debugOpenChange)="onDebugChange($event)"
(timelineOpenChange)="onTimelineChange($event)"
+ (threadsOpenChange)="onThreadsChange($event)"
(newConversation)="onNewConversation()"
/>
+ @if (threadsOpen()) {
+
+
+
+ }
+
@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 4028605ea..498acb4a9 100644
--- a/examples/chat/angular/src/app/shell/demo-shell.component.ts
+++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts
@@ -11,9 +11,17 @@ import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs/operators';
import { agent } from '@ngaf/langgraph';
-import { ChatDebugComponent, ChatInterruptPanelComponent, ChatSubagentsComponent, ChatTimelineSliderComponent, type InterruptAction } from '@ngaf/chat';
+import {
+ ChatDebugComponent,
+ ChatInterruptPanelComponent,
+ ChatSubagentsComponent,
+ ChatThreadListComponent,
+ ChatTimelineSliderComponent,
+ type InterruptAction,
+} from '@ngaf/chat';
import { ControlPalette } from './control-palette.component';
import { PalettePersistence } from './palette-persistence.service';
+import { ThreadsService } from './threads.service';
import { DEMO_AGENT } from './shell-tokens';
export type DemoMode = 'embed' | 'popup' | 'sidebar';
@@ -28,7 +36,15 @@ function modeFromUrl(url: string): DemoMode {
@Component({
selector: 'demo-shell',
standalone: true,
- imports: [RouterOutlet, ControlPalette, ChatDebugComponent, ChatInterruptPanelComponent, ChatSubagentsComponent, ChatTimelineSliderComponent],
+ imports: [
+ RouterOutlet,
+ ControlPalette,
+ ChatDebugComponent,
+ ChatInterruptPanelComponent,
+ ChatSubagentsComponent,
+ ChatThreadListComponent,
+ ChatTimelineSliderComponent,
+ ],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './demo-shell.component.html',
styleUrl: './demo-shell.component.css',
@@ -40,6 +56,7 @@ export class DemoShell {
private readonly router = inject(Router);
private readonly persistence = inject(PalettePersistence);
private readonly document = inject(DOCUMENT);
+ protected readonly threadsSvc = inject(ThreadsService);
constructor() {
// Reflect the chosen theme onto so the
@@ -48,6 +65,14 @@ export class DemoShell {
effect(() => {
this.document.documentElement.setAttribute('data-theme', this.theme());
});
+
+ // Refresh threads list whenever the active thread changes (e.g. after
+ // create or switch) so the panel stays up to date. The effect also
+ // covers the initial load (fires synchronously on first reactive read).
+ effect(() => {
+ void this.threadIdSignal();
+ void this.threadsSvc.refresh();
+ });
}
protected readonly mode = toSignal(
@@ -113,7 +138,10 @@ export class DemoShell {
]);
/** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */
- private readonly threadIdSignal = signal(this.persistence.read('threadId') ?? null);
+ protected readonly threadIdSignal = signal(this.persistence.read('threadId') ?? null);
+
+ /** Whether the threads panel is open. Persisted across reloads. */
+ protected readonly threadsOpen = signal(this.persistence.read('threads') ?? false);
/**
* Shared agent instance. Patched submit injects state.model on every
@@ -206,6 +234,26 @@ export class DemoShell {
});
}
+ protected onThreadsChange(next: boolean): void {
+ this.threadsOpen.set(next);
+ this.persistence.write('threads', next);
+ }
+
+ /** Switch to an existing thread selected from the threads panel. */
+ protected onThreadSelected(threadId: string): void {
+ this.threadIdSignal.set(threadId);
+ this.persistence.write('threadId', threadId);
+ }
+
+ /** Create a new thread via the backend and switch to it. */
+ protected async onNewThread(): Promise {
+ const id = await this.threadsSvc.create();
+ if (id) {
+ this.threadIdSignal.set(id);
+ this.persistence.write('threadId', id);
+ }
+ }
+
/**
* Clear persisted thread id and drop the signal. The next submit
* causes the SDK to create a fresh thread server-side; onThreadId
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 73eff0219..4c919b005 100644
--- a/examples/chat/angular/src/app/shell/palette-persistence.service.ts
+++ b/examples/chat/angular/src/app/shell/palette-persistence.service.ts
@@ -12,6 +12,7 @@ interface PaletteState {
threadId?: string | null;
collapsed?: boolean | null;
timeline?: boolean | null;
+ threads?: boolean | null;
}
type PaletteKey = keyof PaletteState;
diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts
new file mode 100644
index 000000000..ef6e3710f
--- /dev/null
+++ b/examples/chat/angular/src/app/shell/threads.service.ts
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: MIT
+import { Injectable, signal } from '@angular/core';
+import type { Thread } from '@ngaf/chat';
+
+const API_URL = 'http://localhost:2024';
+
+@Injectable({ providedIn: 'root' })
+export class ThreadsService {
+ readonly threads = signal([]);
+
+ async refresh(): Promise {
+ try {
+ const res = await fetch(`${API_URL}/threads/search`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ limit: 50 }),
+ });
+ if (!res.ok) return;
+ const list = await res.json() as Array<{ thread_id: string; metadata?: Record }>;
+ this.threads.set(list.map(t => ({
+ id: t.thread_id,
+ title: this.titleFor(t),
+ })));
+ } catch {
+ // Backend may be down; leave threads as-is.
+ }
+ }
+
+ async create(): Promise {
+ try {
+ const res = await fetch(`${API_URL}/threads`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ });
+ if (!res.ok) return null;
+ const t = await res.json() as { thread_id: string };
+ await this.refresh();
+ return t.thread_id;
+ } catch {
+ return null;
+ }
+ }
+
+ /** Best-effort title: first user message from the thread's checkpoint
+ * if present in metadata, else a truncated thread id. */
+ private titleFor(t: { thread_id: string; metadata?: Record }): string {
+ const meta = t.metadata ?? {};
+ const customTitle = (meta as { title?: string }).title;
+ if (typeof customTitle === 'string' && customTitle.length > 0) return customTitle;
+ return `Thread ${t.thread_id.slice(0, 8)}`;
+ }
+}
diff --git a/examples/chat/smoke/CHECKLIST.md b/examples/chat/smoke/CHECKLIST.md
index 5f09baa3e..90e2b7d43 100644
--- a/examples/chat/smoke/CHECKLIST.md
+++ b/examples/chat/smoke/CHECKLIST.md
@@ -308,3 +308,14 @@ Components NOT yet exercised by the demo (deferred to future media-focused sugge
- [ ] Timeline panel does not obscure the chat input or send button at any supported viewport width
## Multi-thread
+
+- [ ] **Panel toggle** — clicking "Threads off/on" in the control palette opens/closes the threads panel on the left side of the viewport
+- [ ] **Toggle persists** — closing and reopening the browser preserves the threads panel open/closed state
+- [ ] **Threads list visible** — when the panel is open, a list of threads is fetched from the backend and rendered in ``; each item shows a "Thread XXXXXXXX" label (truncated thread ID) or a custom title if stored in metadata
+- [ ] **Active thread highlighted** — the currently active thread is visually distinguished (data-active attribute set) in the list
+- [ ] **Create new thread button** — a "+ New thread" button appears at the top of the thread list; clicking it calls `POST /threads`, creates a fresh thread, and switches the agent to it
+- [ ] **New thread starts in welcome state** — after clicking "+ New thread", the chat area resets to the welcome screen with no prior messages
+- [ ] **Switching threads loads history** — clicking a different thread in the list sets it as active, and the chat area reloads messages from that thread's server-side state
+- [ ] **Persist active thread across reload** — the last active thread ID is stored in localStorage; reloading the page reconnects to the same thread and restores its message history
+- [ ] **Thread list refreshes on switch** — after switching threads, the threads panel refreshes its list from the backend so any newly created threads appear
+- [ ] **No console errors** — opening/closing the panel, switching threads, and creating threads produce no `console.error` or uncaught promise rejections