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
11 changes: 11 additions & 0 deletions examples/chat/angular/src/app/shell/control-palette.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@
<span>Timeline {{ timelineOpen() ? 'on' : 'off' }}</span>
</button>

<button
type="button"
class="palette__toggle"
[class.is-on]="threadsOpen()"
[attr.aria-pressed]="threadsOpen()"
(click)="toggleThreads()"
>
<span class="palette__toggle-dot"></span>
<span>Threads {{ threadsOpen() ? 'on' : 'off' }}</span>
</button>

<button
type="button"
class="palette__action"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class ControlPalette {
readonly themeOptions = input.required<readonly { value: string; label: string }[]>();
readonly debugOpen = input.required<boolean>();
readonly timelineOpen = input.required<boolean>();
readonly threadsOpen = input.required<boolean>();

readonly modeChange = output<DemoMode>();
readonly modelChange = output<string>();
Expand All @@ -40,6 +41,7 @@ export class ControlPalette {
readonly themeChange = output<string>();
readonly debugOpenChange = output<boolean>();
readonly timelineOpenChange = output<boolean>();
readonly threadsOpenChange = output<boolean>();
readonly newConversation = output<void>();

protected readonly collapsed = signal<boolean>(this.persistence.read('collapsed') ?? false);
Expand Down Expand Up @@ -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();
}
Expand Down
16 changes: 16 additions & 0 deletions examples/chat/angular/src/app/shell/demo-shell.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 14 additions & 0 deletions examples/chat/angular/src/app/shell/demo-shell.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,30 @@
[themeOptions]="themeOptions()"
[debugOpen]="debugOpen()"
[timelineOpen]="timelineOpen()"
[threadsOpen]="threadsOpen()"
(modeChange)="onModeChange($event)"
(modelChange)="onModelChange($event)"
(effortChange)="onEffortChange($event)"
(genUiModeChange)="onGenUiModeChange($event)"
(themeChange)="onThemeChange($event)"
(debugOpenChange)="onDebugChange($event)"
(timelineOpenChange)="onTimelineChange($event)"
(threadsOpenChange)="onThreadsChange($event)"
(newConversation)="onNewConversation()"
/>

@if (threadsOpen()) {
<div class="demo-shell__threads-panel" role="region" aria-label="Conversation threads">
<chat-thread-list
[threads]="threadsSvc.threads()"
[activeThreadId]="threadIdSignal() ?? ''"
[showNewThreadButton]="true"
(threadSelected)="onThreadSelected($event)"
(newThreadRequested)="onNewThread()"
/>
</div>
}

@if (agent.interrupt && agent.interrupt()) {
<div class="demo-shell__interrupt-panel" role="region" aria-label="Approval required">
<chat-interrupt-panel [agent]="agent" (action)="onInterruptAction($event)" />
Expand Down
54 changes: 51 additions & 3 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand All @@ -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 <html data-theme="..."> so the
Expand All @@ -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(
Expand Down Expand Up @@ -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<string | null>(this.persistence.read('threadId') ?? null);
protected readonly threadIdSignal = signal<string | null>(this.persistence.read('threadId') ?? null);

/** Whether the threads panel is open. Persisted across reloads. */
protected readonly threadsOpen = signal<boolean>(this.persistence.read('threads') ?? false);

/**
* Shared agent instance. Patched submit injects state.model on every
Expand Down Expand Up @@ -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<void> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface PaletteState {
threadId?: string | null;
collapsed?: boolean | null;
timeline?: boolean | null;
threads?: boolean | null;
}

type PaletteKey = keyof PaletteState;
Expand Down
53 changes: 53 additions & 0 deletions examples/chat/angular/src/app/shell/threads.service.ts
Original file line number Diff line number Diff line change
@@ -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<Thread[]>([]);

async refresh(): Promise<void> {
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<string, unknown> }>;
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<string | null> {
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, unknown> }): 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)}`;
}
}
11 changes: 11 additions & 0 deletions examples/chat/smoke/CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<chat-thread-list>`; 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
Loading