diff --git a/docs/superpowers/specs/2026-05-20-url-thread-routing-design.md b/docs/superpowers/specs/2026-05-20-url-thread-routing-design.md new file mode 100644 index 00000000..4bd4c7fb --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-url-thread-routing-design.md @@ -0,0 +1,112 @@ +# URL-based thread routing — design + +## Goal + +Make the active LangGraph thread part of the URL so links to specific +conversations on the canonical demo are shareable and survive reload. + +## Current state + +`DemoShell.threadIdSignal = signal(persistence.read('threadId') ?? null)`. +The agent watches the signal; `onThreadId` callbacks write it back + +persist to localStorage. Routes are `/embed`, `/popup`, `/sidebar` — +all stateless paths; the active thread lives only in localStorage. +Sharing `/embed` always lands on whichever thread that browser last +used (or a fresh one). + +## URL shape + +``` +//:threadId? +``` + +`:threadId` is optional. Angular doesn't support `?` syntax for +optional params, so each mode gets two route entries: + +```ts +{ path: 'embed', component: EmbedMode }, +{ path: 'embed/:threadId', component: EmbedMode }, +{ path: 'popup', component: PopupMode }, +{ path: 'popup/:threadId', component: PopupMode }, +{ path: 'sidebar', component: SidebarMode }, +{ path: 'sidebar/:threadId', component: SidebarMode }, +``` + +## URL ↔ signal sync (in DemoShell) + +URL is the source of truth when present; localStorage falls back when +the URL has no id. + +Two reactive flows in DemoShell, with guards against render loops: + +1. **URL → signal.** `toSignal(route.firstChild.paramMap)` (the active + mode component owns the param). An `effect` reads the URL's + `threadId` and writes it into `threadIdSignal` if-and-only-if it + differs from the current value. +2. **signal → URL.** A second `effect` reads `threadIdSignal` + the + current `mode()` and `router.navigate(['/', mode, id])` if the URL + doesn't already match. Uses `replaceUrl: false` so the back button + walks through visited threads. + +The "if it differs" guard is the only thing preventing the obvious +URL→signal→URL→signal loop. Both effects already short-circuit +because Angular signal writes are no-ops when the value is unchanged, +but `router.navigate` doesn't short-circuit, so the explicit URL +comparison in flow #2 is required. + +## Invalid id handling + +When a route loads with a `:threadId` the user has never seen (typo, +deleted thread, link from another browser), we silently redirect to +the bare mode path: + +```ts +const thread = await threadsSvc.getThread(id); +if (!thread) router.navigate(['/', mode()], { replaceUrl: true }); +``` + +`replaceUrl: true` so the back button doesn't reload the broken URL. + +This requires a new method on `LangGraphThreadsAdapter`: + +```ts +async getThread(threadId: string): Promise +``` + +Wraps `client.threads.get(id)`. Returns `null` on 404 (caught from +the SDK's thrown error); rethrows on other failures so genuine +network errors don't get masked as "thread missing." + +## Mode switching preserves thread + +`/embed/abc` → click "Popup" tab → `/popup/abc`. The `onModeChange` +handler already exists; updates to include the current thread id: + +```ts +protected onModeChange(next: DemoMode | string): void { + const id = this.threadIdSignal(); + void this.router.navigate(id ? ['/', next, id] : ['/', next]); +} +``` + +## Out of scope + +- Server-side render of ``/og:* tags for richer link previews +- Restoring scroll position to the last-read message on reload +- Authentication / private threads — these URLs are already public on + the demo and that's fine + +## Test plan + +- `LangGraphThreadsAdapter.getThread()` — returns `Thread` for an + existing id, returns `null` for a missing id, rethrows on other + errors +- Demo route loads `/embed/<existing-id>` → `threadIdSignal()` === + that id, messages from that thread render +- Demo route loads `/embed/<bogus-id>` → silently redirects to + `/embed`, fresh chat +- Click a thread in the sidenav → URL updates to `/<mode>/<id>` +- Click mode toggle while a thread is active → URL switches mode but + keeps the id +- Browser back/forward across visited threads — agent state matches + the URL at each step diff --git a/examples/chat/angular/src/app/app.routes.ts b/examples/chat/angular/src/app/app.routes.ts index 1c7ebcfa..799e5dd1 100644 --- a/examples/chat/angular/src/app/app.routes.ts +++ b/examples/chat/angular/src/app/app.routes.ts @@ -1,6 +1,12 @@ // SPDX-License-Identifier: MIT import { Routes } from '@angular/router'; +// Each mode gets two route entries: a stateless `<mode>` and a +// thread-scoped `<mode>/:threadId`. Angular Router doesn't support +// `?`-style optional params, hence the duplication. DemoShell's +// URL ↔ signal sync (see spec 2026-05-20-url-thread-routing-design.md) +// reads `route.firstChild.paramMap.threadId` so both shapes feed the +// same handler. export const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: 'embed' }, { @@ -13,16 +19,31 @@ export const routes: Routes = [ loadComponent: () => import('./modes/embed-mode.component').then((m) => m.EmbedMode), }, + { + path: 'embed/:threadId', + loadComponent: () => + import('./modes/embed-mode.component').then((m) => m.EmbedMode), + }, { path: 'popup', loadComponent: () => import('./modes/popup-mode.component').then((m) => m.PopupMode), }, + { + path: 'popup/:threadId', + loadComponent: () => + import('./modes/popup-mode.component').then((m) => m.PopupMode), + }, { path: 'sidebar', loadComponent: () => import('./modes/sidebar-mode.component').then((m) => m.SidebarMode), }, + { + path: 'sidebar/:threadId', + loadComponent: () => + import('./modes/sidebar-mode.component').then((m) => m.SidebarMode), + }, ], }, { path: '**', redirectTo: 'embed' }, 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 8616609b..da9bed0e 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -39,9 +39,14 @@ export type DemoMode = 'embed' | 'popup' | 'sidebar'; const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const; const TELEMETRY_SURFACE = 'canonical_demo'; -function modeFromUrl(url: string): DemoMode { - const seg = url.split('?')[0].split('/').filter(Boolean)[0]; - return (MODES as readonly string[]).includes(seg) ? (seg as DemoMode) : 'embed'; +/** Parse `/embed`, `/embed/<threadId>`, `/popup/<threadId>` etc. into + * `{mode, threadId}`. Source of truth for URL ↔ signal sync — see + * spec 2026-05-20-url-thread-routing-design.md. */ +function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { + const segs = url.split('?')[0].split('#')[0].split('/').filter(Boolean); + const mode = (MODES as readonly string[]).includes(segs[0]) ? (segs[0] as DemoMode) : 'embed'; + const threadId = segs[1] && segs[1].length > 0 ? segs[1] : null; + return { mode, threadId }; } @Component({ @@ -109,6 +114,46 @@ export class DemoShell { void this.threadsSvc.refresh(); }); + // URL → signal. When the URL's threadId changes (paste link, back/ + // forward, programmatic navigation), reflect it into threadIdSignal. + // The compare-and-set guard breaks the obvious URL→signal→URL loop: + // by the time the signal→URL effect below fires, both values match + // and `router.navigate` is skipped. + // URL → signal sync. + effect(() => { + const urlId = this.urlThreadId(); + if (urlId !== this.threadIdSignal()) { + this.threadIdSignal.set(urlId); + } + }); + + // Validate URL thread ids whenever they appear. Decoupled from the + // sync effect above: on initial load the signal is hydrated from + // the URL synchronously (field initializer), so the sync guard + // would skip validation. This effect runs once per distinct id, + // including the initial one. Cache last-validated to avoid + // re-hitting the server on signal flips that round-trip the same + // id back through. + let lastValidated: string | null = null; + effect(() => { + const urlId = this.urlThreadId(); + if (urlId && urlId !== lastValidated) { + lastValidated = urlId; + void this.validateUrlThreadId(urlId); + } + }); + + // signal → URL. When the agent auto-creates a thread, the sidenav + // switches threads, or onNewThread fires, push the new id into the + // URL. Skips when the URL already matches (also breaks the loop). + effect(() => { + const sigId = this.threadIdSignal(); + const { mode, threadId: urlId } = this.urlState(); + if (sigId === urlId) return; + const cmds: unknown[] = sigId ? ['/', mode, sigId] : ['/', mode]; + void this.router.navigate(cmds as string[]); + }); + // Refresh threads list when an agent run completes. The backend writes // metadata.title on the first user message via _maybe_write_thread_title; // a refresh after run-end picks up the new title in the drawer without @@ -130,16 +175,22 @@ export class DemoShell { }); } - protected readonly mode = toSignal( + /** Parsed URL — single source for both the active mode AND the URL's + * thread id. Refreshes on every NavigationEnd so back/forward and + * programmatic navigations both feed downstream effects. */ + private readonly urlState = toSignal( this.router.events.pipe( filter((e): e is NavigationEnd => e instanceof NavigationEnd), - map((e) => modeFromUrl(e.urlAfterRedirects)), - startWith(modeFromUrl(this.router.url)), + map((e) => parseUrl(e.urlAfterRedirects)), + startWith(parseUrl(this.router.url)), takeUntilDestroyed(), ), - { initialValue: modeFromUrl(this.router.url) }, + { initialValue: parseUrl(this.router.url) }, ); + protected readonly mode = computed<DemoMode>(() => this.urlState().mode); + private readonly urlThreadId = computed<string | null>(() => this.urlState().threadId); + /** * Source of truth for the model picker. The shell owns it; the * patched submit injects it into state on every send. @@ -256,8 +307,11 @@ export class DemoShell { { value: 'material-light', label: 'Material light' }, ]); - /** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */ - protected readonly threadIdSignal = signal<string | null>(this.persistence.read('threadId') ?? null); + /** Active thread id. URL is the source of truth (see urlState above); + * this signal initialises from the URL on construction and is kept in + * sync by the bidirectional effects in the constructor. The agent + * watches this signal directly. */ + protected readonly threadIdSignal = signal<string | null>(parseUrl(this.router.url).threadId); /** Title of the currently-selected thread, or 'New chat' if none. The * Python graph writes thread.metadata.title from the first user message @@ -293,18 +347,12 @@ export class DemoShell { protected readonly threadActions: ThreadActionAdapter = { delete: async (id) => { await this.threadsSvc.delete(id); - if (this.threadIdSignal() === id) { - this.threadIdSignal.set(null); - this.persistence.write('threadId', null); - } + if (this.threadIdSignal() === id) this.threadIdSignal.set(null); }, rename: (id, title) => this.threadsSvc.rename(id, title), archive: async (id) => { await this.threadsSvc.archive(id); - if (this.threadIdSignal() === id) { - this.threadIdSignal.set(null); - this.persistence.write('threadId', null); - } + if (this.threadIdSignal() === id) this.threadIdSignal.set(null); }, unarchive: (id) => this.threadsSvc.unarchive(id), pin: (id) => this.threadsSvc.pin(id), @@ -326,8 +374,10 @@ export class DemoShell { assistantId: environment.assistantId, threadId: this.threadIdSignal, onThreadId: (id: string) => { + // The signal→URL effect picks this up and stamps the new id + // into the URL — no persistence write needed any more, URL is + // the source of truth. this.threadIdSignal.set(id); - this.persistence.write('threadId', id); }, // Phase 3B: tells SubagentTracker to treat `research` tool calls as // subagent dispatches and to materialize agent.subagents() from the @@ -361,7 +411,21 @@ export class DemoShell { })(); protected onModeChange(next: DemoMode | string): void { - void this.router.navigate(['/' + next]); + // Preserve the active thread across mode switches: /embed/abc → + // /popup/abc keeps the conversation visible in the new chrome. + const id = this.threadIdSignal(); + void this.router.navigate(id ? ['/', next, id] : ['/', next]); + } + + /** Silently redirect to the bare mode path when the URL's threadId + * resolves to a 404. Uses `replaceUrl: true` so the back button + * doesn't reload the broken link. Non-404 errors propagate from + * the adapter as-is (genuine transport failures shouldn't be + * swallowed). */ + private async validateUrlThreadId(threadId: string): Promise<void> { + const thread = await this.threadsSvc.getThread(threadId); + if (thread) return; + await this.router.navigate(['/', this.mode()], { replaceUrl: true }); } onModelChange(next: string): void { @@ -408,7 +472,6 @@ export class DemoShell { /** Switch to an existing thread selected from the threads panel. */ protected onThreadSelected(threadId: string): void { this.threadIdSignal.set(threadId); - this.persistence.write('threadId', threadId); } protected onProjectSelected(projectId: string): void { @@ -431,10 +494,7 @@ export class DemoShell { protected async onNewThread(): Promise<void> { const sel = this.selectedProjectId(); const id = await this.threadsSvc.create(sel ? { projectId: sel } : {}); - if (id) { - this.threadIdSignal.set(id); - this.persistence.write('threadId', id); - } + if (id) this.threadIdSignal.set(id); } /** diff --git a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts index 46c46532..232ac3ce 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts @@ -14,14 +14,16 @@ function mockClient(searchReturn: unknown[] = []): { update: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; + get: ReturnType<typeof vi.fn>; } { const search = vi.fn().mockResolvedValue(searchReturn); const update = vi.fn().mockResolvedValue(undefined); const del = vi.fn().mockResolvedValue(undefined); const create = vi.fn().mockResolvedValue({ thread_id: 'new-thread' }); + const get = vi.fn(); return { - client: { threads: { search, update, delete: del, create } } as unknown as Client, - search, update, del, create, + client: { threads: { search, update, delete: del, create, get } } as unknown as Client, + search, update, del, create, get, }; } @@ -98,6 +100,43 @@ describe('LangGraphThreadsAdapter', () => { expect(m.update).toHaveBeenCalledWith('t1', { metadata: { thread_title: 'New title' } }); }); + it('getThread() returns a mapped Thread when the SDK resolves', async () => { + const m = mockClient(); + m.get.mockResolvedValue({ + thread_id: 'tx', + updated_at: '2026-05-20T00:00:00Z', + metadata: { thread_title: 'hello' }, + }); + const svc = configure(m.client); + const result = await svc.getThread('tx'); + expect(m.get).toHaveBeenCalledWith('tx'); + expect(result).toEqual(expect.objectContaining({ id: 'tx', title: 'hello' })); + }); + + it('getThread() returns null when the SDK throws a 404', async () => { + const m = mockClient(); + const err = Object.assign(new Error('not found'), { status: 404 }); + m.get.mockRejectedValue(err); + const svc = configure(m.client); + expect(await svc.getThread('missing')).toBeNull(); + }); + + it('getThread() returns null when 404 lives on response.status', async () => { + const m = mockClient(); + const err = Object.assign(new Error('not found'), { response: { status: 404 } }); + m.get.mockRejectedValue(err); + const svc = configure(m.client); + expect(await svc.getThread('missing')).toBeNull(); + }); + + it('getThread() rethrows non-404 errors so transport failures are visible', async () => { + const m = mockClient(); + const err = Object.assign(new Error('server exploded'), { status: 500 }); + m.get.mockRejectedValue(err); + const svc = configure(m.client); + await expect(svc.getThread('any')).rejects.toThrow('server exploded'); + }); + it('logs but does not throw when refresh() fails', async () => { const search = vi.fn().mockRejectedValue(new Error('boom')); const client = { threads: { search } } as unknown as Client; diff --git a/libs/langgraph/src/lib/threads/threads-adapter.ts b/libs/langgraph/src/lib/threads/threads-adapter.ts index 6771c91c..aad8321a 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.ts @@ -113,6 +113,29 @@ export class LangGraphThreadsAdapter { } } + /** Fetch a single thread by id. Returns `null` when the server + * returns 404 (thread doesn't exist) so callers can distinguish + * "missing" from "couldn't reach the server" — genuine network + * errors rethrow. Used by URL-based thread routing to validate a + * pasted/shared thread id before activating it. */ + async getThread(threadId: string): Promise<Thread | null> { + try { + const t = await this.client.threads.get(threadId); + return this.toThread(t); + } catch (e) { + // SDK throws HTTPError-like objects without a typed error class; + // sniff status on the error or its nested response. Treat both + // 404 (server says "no such thread") and 422 (server says "id + // isn't even a valid UUID") as "missing" — both warrant the + // same caller behavior (redirect to a fresh chat). + const status = + (e as { status?: number }).status ?? + (e as { response?: { status?: number } }).response?.status; + if (status === 404 || status === 422) return null; + throw e; + } + } + async create(metadata: Record<string, unknown> = {}): Promise<string | null> { try { const t = await this.client.threads.create({ metadata });