From 2cdf7cfee41875b8495a3357d79f7aeb8352bcb0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 20 May 2026 21:47:12 -0700 Subject: [PATCH 01/12] =?UTF-8?q?Revert=20"feat(examples-chat):=20URL-base?= =?UTF-8?q?d=20thread=20routing=20=E2=80=94=20//:threadId=20(#500)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 12017127966b4222f467de7b16516db30745aa9f. --- .../2026-05-20-url-thread-routing-design.md | 112 ------------------ examples/chat/angular/src/app/app.routes.ts | 21 ---- .../src/app/shell/demo-shell.component.ts | 108 ++++------------- .../src/lib/threads/threads-adapter.spec.ts | 43 +------ .../src/lib/threads/threads-adapter.ts | 23 ---- 5 files changed, 26 insertions(+), 281 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-20-url-thread-routing-design.md 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 deleted file mode 100644 index 4bd4c7fb6..000000000 --- a/docs/superpowers/specs/2026-05-20-url-thread-routing-design.md +++ /dev/null @@ -1,112 +0,0 @@ -# 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 799e5dd1b..1c7ebcfaa 100644 --- a/examples/chat/angular/src/app/app.routes.ts +++ b/examples/chat/angular/src/app/app.routes.ts @@ -1,12 +1,6 @@ // 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' }, { @@ -19,31 +13,16 @@ 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 da9bed0e2..8616609b5 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -39,14 +39,9 @@ export type DemoMode = 'embed' | 'popup' | 'sidebar'; const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const; const TELEMETRY_SURFACE = 'canonical_demo'; -/** 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 }; +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'; } @Component({ @@ -114,46 +109,6 @@ 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 @@ -175,22 +130,16 @@ export class DemoShell { }); } - /** 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( + protected readonly mode = toSignal( this.router.events.pipe( filter((e): e is NavigationEnd => e instanceof NavigationEnd), - map((e) => parseUrl(e.urlAfterRedirects)), - startWith(parseUrl(this.router.url)), + map((e) => modeFromUrl(e.urlAfterRedirects)), + startWith(modeFromUrl(this.router.url)), takeUntilDestroyed(), ), - { initialValue: parseUrl(this.router.url) }, + { initialValue: modeFromUrl(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. @@ -307,11 +256,8 @@ export class DemoShell { { value: 'material-light', label: 'Material light' }, ]); - /** 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); + /** 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); /** Title of the currently-selected thread, or 'New chat' if none. The * Python graph writes thread.metadata.title from the first user message @@ -347,12 +293,18 @@ export class DemoShell { protected readonly threadActions: ThreadActionAdapter = { delete: async (id) => { await this.threadsSvc.delete(id); - if (this.threadIdSignal() === id) this.threadIdSignal.set(null); + if (this.threadIdSignal() === id) { + this.threadIdSignal.set(null); + this.persistence.write('threadId', 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); + if (this.threadIdSignal() === id) { + this.threadIdSignal.set(null); + this.persistence.write('threadId', null); + } }, unarchive: (id) => this.threadsSvc.unarchive(id), pin: (id) => this.threadsSvc.pin(id), @@ -374,10 +326,8 @@ 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 @@ -411,21 +361,7 @@ export class DemoShell { })(); protected onModeChange(next: DemoMode | string): void { - // 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 }); + void this.router.navigate(['/' + next]); } onModelChange(next: string): void { @@ -472,6 +408,7 @@ 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 { @@ -494,7 +431,10 @@ 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); + if (id) { + this.threadIdSignal.set(id); + this.persistence.write('threadId', id); + } } /** diff --git a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts index 232ac3ce4..46c465327 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts @@ -14,16 +14,14 @@ 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, get } } as unknown as Client, - search, update, del, create, get, + client: { threads: { search, update, delete: del, create } } as unknown as Client, + search, update, del, create, }; } @@ -100,43 +98,6 @@ 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 aad8321ae..6771c91c0 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.ts @@ -113,29 +113,6 @@ 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 }); From 0688fa152d623b3651562494826f0fb1aac50fca Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 09:32:51 -0700 Subject: [PATCH 02/12] =?UTF-8?q?docs(spec):=20demo=20URL=20routing=20?= =?UTF-8?q?=E2=80=94=20full=20state=20in=20URL=20for=20shareable=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks decisions: full state in URL (thread id in path + agent knobs + theme + color scheme + project as query params), defaults omitted to keep links short, ephemeral hydration semantics (URL writes signals but not localStorage, so shared links don't infect recipients' preferences), thread id moves from localStorage to URL (drops threadId persistence entirely). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../2026-05-20-demo-url-routing-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-demo-url-routing-design.md diff --git a/docs/superpowers/specs/2026-05-20-demo-url-routing-design.md b/docs/superpowers/specs/2026-05-20-demo-url-routing-design.md new file mode 100644 index 000000000..67e69c1d6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-demo-url-routing-design.md @@ -0,0 +1,167 @@ +# Demo URL routing — Design + +**Status:** Approved +**Date:** 2026-05-20 +**Goal:** Make every shareable thing about a demo session round-trip through the URL — active thread, agent knobs, theme, color scheme, selected project — so visitors can share their session as a link and recipients land on the exact same state. Ephemeral semantics: shared URL state overrides on visit but does not write to the recipient's localStorage. + +## Why now + +Today the canonical demo persists state in `localStorage` only. A visitor who customizes their setup (picks a model, switches to dark theme, opens a thread) has no way to share that exact view. The URL is just `/embed` regardless. Adding URL-based state turns the demo into a meaningful sharing surface for "look at this thread I made on Threadplane" without auth gating. + +## Decisions locked during brainstorming + +| Decision | Choice | +|---|---| +| What state goes in the URL | **Full demo state**: thread id, model, effort, gen-ui mode, theme, color scheme, selected project | +| Thread id placement | **Path segment** (`/<mode>/<thread-id>`) — canonical identity. ChatGPT/Claude-style. | +| Knob placement | **Query params**, omitted when at default value (`?model=gpt-5-nano&effort=high`) | +| Permission/auth on shared threads | **Out of scope** — public demo surface; thread ids are guessable but threads contain no sensitive data | +| URL → localStorage write semantics | **Ephemeral** — URL writes signals on visit but does NOT write to localStorage. localStorage only updates when the user explicitly changes a knob via the UI. Receivers of a shared link don't get their preferences overwritten. | + +## URL shape + +``` +/<mode>[/<thread-id>][?model=&effort=&genui=&theme=&color=&project=] +``` + +Examples: +- `/embed` — fresh demo, no thread, all defaults +- `/embed/019e434c-...` — load that thread, defaults for everything else +- `/embed/019e434c-...?model=gpt-5-nano&effort=high` — thread + non-default agent knobs +- `/popup/abc123?genui=json-render&theme=material-dark&color=light` — full state, popup mode +- `/sidebar?theme=material-dark` — no thread yet, custom theme + +**Default values omitted from URL.** A user who hasn't changed anything from defaults shares `/embed/<id>` — short, meaningful. The defaults table (matching today's persistence fallbacks): + +| Param | Default | Source signal | +|---|---|---| +| `model` | `gpt-5-mini` | `model` | +| `effort` | `minimal` | `effort` | +| `genui` | `a2ui` | `genUiMode` | +| `theme` | `default-dark` | `theme` | +| `color` | `dark` | `colorScheme` | +| `project` | `null` (omitted) | `selectedProjectId` | + +## Architecture + +### Routes (`app.routes.ts`) + +Each mode gains a sibling route that accepts an optional `:threadId` path segment. Both routes load the same mode component: + +```ts +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { + path: '', + loadComponent: () => import('./shell/demo-shell.component').then(m => m.DemoShell), + children: [ + { path: 'embed', 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' }, +]; +``` + +The mode components stay untouched — `demo-shell` reads route params through `ActivatedRoute.firstChild` (or by walking the activated route tree on NavigationEnd events). + +### URL → signal hydration (one-shot, on mount + on every navigation) + +A new private method `readUrlState()` runs once on `DemoShell` mount and on every `NavigationEnd`: + +1. Resolve the deepest activated route via `router.routerState.root.firstChild?.firstChild` (the child mode route). +2. Read `paramMap.get('threadId')` — null if route has no `:threadId`. +3. Read `queryParamMap` for `model`, `effort`, `genui`, `theme`, `color`, `project`. +4. For each present URL value, **set the corresponding signal**. Do NOT write to localStorage from this path. +5. For absent URL values, leave signal at its current state (init value already read from localStorage at signal-construction time). + +The signal init order on first construction stays: + +```ts +readonly model = signal<string>(this.persistence.read('model') ?? 'gpt-5-mini'); +``` + +This is the localStorage → default fallback. Then `readUrlState()` overrides any signal whose URL value is present. Effective precedence: **URL → localStorage → default**. ✓ + +### Signal → URL writes + +Three categories of writes, three policies: + +| Category | Trigger | Router call | History behavior | +|---|---|---|---| +| **Mode change** | `onModeChange(next)` | `router.navigate(['/' + next, this.threadIdSignal() ?? ''], { queryParamsHandling: 'preserve' })` | **Push** — back/forward navigates between modes | +| **Thread switch** | `onThreadSelected`, `onNewThread`, agent `onThreadId` callback | `router.navigate(['/' + this.mode(), id], { queryParamsHandling: 'preserve' })` | **Push** — each thread is a distinct URL | +| **Knob change** | `onModelChange`, `onEffortChange`, `onGenUiModeChange`, `onThemeChange`, `onColorSchemeChange`, `onProjectSelected` | `router.navigate([], { queryParams: <computed-full-set>, replaceUrl: true })` (no `queryParamsHandling` → replace all query params with this object; keys whose value is `null` are dropped from the URL) | **Replace** — dropdown clicks don't pollute browser history | + +A helper `buildQueryParams()` returns an object where every knob is mapped to either its non-default value or `null`. Angular's router omits null keys from the URL automatically when `queryParamsHandling` is left unset. + +### Mode signal becomes derived from route, not URL-parsed + +Today's `mode` signal parses `router.url` manually via `modeFromUrl` (`split('/').filter(Boolean)[0]`). Keep that helper — it correctly extracts the first non-empty segment regardless of whether a `:threadId` follows. Updated wiring: subscribe to `NavigationEnd` and apply `modeFromUrl(urlAfterRedirects)` as today. No semantic change to the mode signal; this PR just adds threadId + query-param hydration alongside. + +### threadIdSignal becomes URL-driven, drops localStorage + +Today: `threadIdSignal = signal(this.persistence.read('threadId') ?? null)` — init from localStorage; `onThreadId` callback writes back to localStorage. + +After: `threadIdSignal = signal<string | null>(null)` — init null. The `readUrlState()` setter populates it from the path. The `onThreadId` callback navigates the router (which updates the URL), not localStorage. Remove `persistence.read('threadId')` and all `persistence.write('threadId', ...)` calls. + +This matches the user's earlier "drawerOpen always starts false; stop persisting drawer state" pattern — transient routing state belongs in the URL, not localStorage. + +### Knob persistence (unchanged on user action) + +The existing `onModelChange`, `onEffortChange`, etc. handlers continue to `persistence.write('model', next)` etc. — so habitual visitors keep their preferences across sessions. The "ephemeral" semantic ONLY blocks URL-hydration writes; explicit user actions still persist. + +### Browser back/forward + +Native — every Router navigation pushes (or replaces) history. Going back from `/embed/abc?model=nano` to `/embed/abc?model=mini` is just popstate → Router → `readUrlState()` → signal updates. + +## Files touched (demo-only) + +| File | Change | +|---|---| +| `examples/chat/angular/src/app/app.routes.ts` | Add 3 sibling `:threadId` routes | +| `examples/chat/angular/src/app/shell/demo-shell.component.ts` | URL-bridge methods (`readUrlState`, `buildQueryParams`, knob navigation), drop `threadId` persistence read+write, derive `mode` from activated route | +| `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` | Tests (below) | + +No library changes. No lib version bump needed. + +## Testing + +Unit tests in `demo-shell.component.spec.ts`: + +1. **Routes accept `/embed`, `/embed/:threadId`, `/popup`, `/popup/:threadId`, `/sidebar`, `/sidebar/:threadId`** — navigate to each and assert the demo-shell mounts. +2. **threadId hydration from URL** — navigate to `/embed/abc123` → assert `threadIdSignal()` is `'abc123'`. +3. **threadId is null when route has no `:threadId`** — navigate to `/embed` → assert `threadIdSignal()` is `null`. +4. **Knob hydration from query params** — navigate to `/embed?model=gpt-5-nano&effort=high` → assert `model() === 'gpt-5-nano'` and `effort() === 'high'`. +5. **Default values omitted from URL** — call `onModelChange('gpt-5-mini')` (the default) → assert URL has no `model=` param. +6. **Non-default values appear in URL** — call `onModelChange('gpt-5-nano')` → assert URL has `?model=gpt-5-nano`. +7. **Mode change preserves thread + query params** — at `/embed/abc123?model=gpt-5-nano`, call `onModeChange('popup')` → assert URL becomes `/popup/abc123?model=gpt-5-nano`. +8. **Thread switch updates URL** — call `onThreadSelected('xyz')` → assert URL path is `/embed/xyz`. +9. **Ephemeral hydration**: navigate to `/embed?theme=material-dark` and assert `persistence.write('theme', ...)` was NOT called. +10. **User action persists**: call `onThemeChange('material-dark')` and assert `persistence.write('theme', 'material-dark')` was called. + +E2E (Playwright, `examples/chat/angular/e2e/url-routing.spec.ts`, new file): + +- Open `/embed/<known-thread-id>` directly → confirm the chat shows that thread's messages. +- Open `/embed?model=gpt-5-nano` → confirm the model picker shows gpt-5-nano selected. +- Switch the mode segmented control from Embed to Popup → URL changes from `/embed/.../?model=...` to `/popup/.../?model=...`, query params preserved. + +## Out of scope + +- A visible "Copy link" UI button (the URL is the link — copy from the address bar). Could land in a follow-up if a CTA is desired. +- OG tags / server-side rendering for previews on social platforms. +- Auth or read-only modes for shared threads. +- URL state for sub-controls inside `chat-input` (model picker pill, etc.) — those mirror demo-shell signals already and ride along. +- Cross-tab synchronization of state (BroadcastChannel etc.) — separate concern from URL sharing. +- A migration path that reads the old `localStorage.threadId` on first load after this PR ships. Returning users will see a clean welcome state once and re-pick the thread they want (or hit a bookmark). Acceptable churn. + +## References + +- Current `app.routes.ts:1-29` — three flat routes, no thread param +- Current `demo-shell.component.ts:42-45` — `modeFromUrl` URL parser (replaced by route-driven derivation) +- Current `demo-shell.component.ts:133-141` — `mode` toSignal derived from NavigationEnd (kept, but reads from activated route's routeConfig.path instead of parsing `router.url`) +- Current persistence keys (in `palette-persistence.service.ts`) — `model`, `effort`, `genUiMode`, `theme`, `colorScheme`, `selectedProjectId`, formerly `threadId` (removed by this PR) From 817c98521116fbcebedb67bf7211d4cd788d6cc6 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 09:47:52 -0700 Subject: [PATCH 03/12] =?UTF-8?q?docs(plan):=20demo=20URL=20routing=20?= =?UTF-8?q?=E2=80=94=20bite-sized=20TDD=20task=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../plans/2026-05-20-demo-url-routing.md | 800 ++++++++++++++++++ 1 file changed, 800 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-demo-url-routing.md diff --git a/docs/superpowers/plans/2026-05-20-demo-url-routing.md b/docs/superpowers/plans/2026-05-20-demo-url-routing.md new file mode 100644 index 000000000..4688ee403 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-demo-url-routing.md @@ -0,0 +1,800 @@ +# Demo URL Routing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every shareable thing about the canonical demo round-trip through the URL — active thread id as a path segment, agent knobs (model, effort, genui, theme, color, project) as query params omitted at defaults — so visitors can share their session as a link. + +**Architecture:** Add `:threadId` sibling routes for each mode. Hydrate signals from URL on every `NavigationEnd` (ephemeral — does NOT write localStorage). On user actions, both write localStorage AND navigate the router with `replaceUrl: true` for knobs and a push for thread/mode changes. Drop `threadId` from localStorage entirely. + +**Tech Stack:** Angular 20 (standalone, signals), `@angular/router`, Vitest, Playwright. + +**Spec:** `docs/superpowers/specs/2026-05-20-demo-url-routing-design.md` + +--- + +## File Structure + +| File | Role | +|---|---| +| `examples/chat/angular/src/app/app.routes.ts` | Add 3 sibling `:threadId` routes | +| `examples/chat/angular/src/app/shell/demo-shell.component.ts` | URL hydration (`readUrlState`), URL writes (`buildQueryParams`, knob/mode/thread nav), drop `threadId` persistence | +| `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` | Unit coverage for hydration + navigation + ephemeral semantics | +| `examples/chat/angular/e2e/url-routing.spec.ts` | New: Playwright deep-link smoke | + +--- + +## Task 1: Add sibling `:threadId` routes + +**Files:** +- Modify: `examples/chat/angular/src/app/app.routes.ts` + +- [ ] **Step 1: Update routes** + +Replace the 3 child routes with 6 sibling routes (each mode plus a `:threadId` variant), all loading the same mode component. + +```ts +// SPDX-License-Identifier: MIT +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { + path: '', + loadComponent: () => + import('./shell/demo-shell.component').then((m) => m.DemoShell), + children: [ + { + path: 'embed', + 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' }, +]; +``` + +- [ ] **Step 2: Smoke-test the build** + +Run: `npx nx build chat-angular-example --skip-nx-cache=false` (or the project's standard build command). +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add examples/chat/angular/src/app/app.routes.ts +git commit -m "feat(demo-routes): add :threadId sibling routes for each mode" +``` + +--- + +## Task 2: Make `threadIdSignal` URL-driven; drop its persistence + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Test: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` + +- [ ] **Step 1: Write a failing test for URL → threadId hydration** + +Append to `demo-shell.component.spec.ts`: + +```ts +describe('DemoShell — threadId hydration', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('hydrates threadIdSignal from /embed/:threadId', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed/abc123'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { threadIdSignal: () => string | null }; + expect(cmp.threadIdSignal()).toBe('abc123'); + }); + + it('leaves threadIdSignal null when route has no :threadId', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { threadIdSignal: () => string | null }; + expect(cmp.threadIdSignal()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx nx test chat-angular-example -- --testNamePattern="threadId hydration"` +Expected: FAIL — hydration doesn't yet read from the route. + +- [ ] **Step 3: Change `threadIdSignal` init** + +In `demo-shell.component.ts`, replace: + +```ts +protected readonly threadIdSignal = signal<string | null>(this.persistence.read('threadId') ?? null); +``` + +with: + +```ts +protected readonly threadIdSignal = signal<string | null>(null); +``` + +- [ ] **Step 4: Add `readUrlState()` hydration** + +In the constructor of `DemoShell`, add (after the existing effects): + +```ts +this.readUrlState(); +this.router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(), + ) + .subscribe(() => this.readUrlState()); +``` + +Add the method on `DemoShell`: + +```ts +private readUrlState(): void { + let route = this.router.routerState.root; + while (route.firstChild) route = route.firstChild; + const threadId = route.snapshot.paramMap.get('threadId'); + this.threadIdSignal.set(threadId); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx nx test chat-angular-example -- --testNamePattern="threadId hydration"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts \ + examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +git commit -m "feat(demo-shell): hydrate threadIdSignal from route param" +``` + +--- + +## Task 3: Route thread switches/creates through the router + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Test: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` + +- [ ] **Step 1: Write a failing test for `onThreadSelected` navigating the URL** + +Append: + +```ts +describe('DemoShell — thread switch navigates URL', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('navigates to /embed/<id> when onThreadSelected fires', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { + onThreadSelected: (id: string) => void; + }; + cmp.onThreadSelected('xyz'); + await fx.whenStable(); + expect(router.url).toBe('/embed/xyz'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test chat-angular-example -- --testNamePattern="thread switch navigates URL"` +Expected: FAIL — `onThreadSelected` still writes the signal directly, not the router. + +- [ ] **Step 3: Update `onThreadSelected`, `onNewThread`, `threadActions`, and agent's `onThreadId` callback** + +Replace the methods + actions in `demo-shell.component.ts`: + +```ts +protected onThreadSelected(threadId: string): void { + void this.router.navigate(['/' + this.mode(), threadId], { queryParamsHandling: 'preserve' }); +} + +protected async onNewThread(): Promise<void> { + const sel = this.selectedProjectId(); + const id = await this.threadsSvc.create(sel ? { projectId: sel } : {}); + if (id) { + void this.router.navigate(['/' + this.mode(), id], { queryParamsHandling: 'preserve' }); + } +} +``` + +Update `threadActions.delete` and `threadActions.archive` to clear via URL (no persistence write): + +```ts +protected readonly threadActions: ThreadActionAdapter = { + delete: async (id) => { + await this.threadsSvc.delete(id); + if (this.threadIdSignal() === id) { + void this.router.navigate(['/' + this.mode()], { queryParamsHandling: 'preserve' }); + } + }, + rename: (id, title) => this.threadsSvc.rename(id, title), + archive: async (id) => { + await this.threadsSvc.archive(id); + if (this.threadIdSignal() === id) { + void this.router.navigate(['/' + this.mode()], { queryParamsHandling: 'preserve' }); + } + }, + unarchive: (id) => this.threadsSvc.unarchive(id), + pin: (id) => this.threadsSvc.pin(id), + unpin: (id) => this.threadsSvc.unpin(id), + moveToProject: async (id, projectId) => { + await this.threadsSvc.moveToProject(id, projectId); + }, + reorderPinned: (id, beforeId) => this.threadsSvc.reorderPinned(id, beforeId), +}; +``` + +Update the agent's `onThreadId` callback (remove the persistence write — `readUrlState()` will pick it up from the URL once the SDK callback triggers a navigation): + +```ts +onThreadId: (id: string) => { + void this.router.navigate(['/' + this.mode(), id], { + queryParamsHandling: 'preserve', + replaceUrl: true, + }); +}, +``` + +Note: `replaceUrl: true` on the SDK-driven thread-id callback prevents an extra history entry when the backend allocates the id at submit time. The user's explicit thread-pick (`onThreadSelected`) still pushes. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx nx test chat-angular-example -- --testNamePattern="thread switch navigates URL"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts \ + examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +git commit -m "feat(demo-shell): route thread switches/creates through the router" +``` + +--- + +## Task 4: Preserve thread + query params on mode change + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Test: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` + +- [ ] **Step 1: Write a failing test** + +Append: + +```ts +describe('DemoShell — mode change preserves thread + query', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: 'popup', component: DemoShell }, + { path: 'popup/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('preserves :threadId and ?model when switching mode', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed/abc?model=gpt-5-nano'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onModeChange: (m: string) => void }; + cmp.onModeChange('popup'); + await fx.whenStable(); + expect(router.url).toBe('/popup/abc?model=gpt-5-nano'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test chat-angular-example -- --testNamePattern="mode change preserves"` +Expected: FAIL — current `onModeChange` drops the thread id and query. + +- [ ] **Step 3: Update `onModeChange`** + +```ts +protected onModeChange(next: DemoMode | string): void { + const id = this.threadIdSignal(); + const segments = id ? ['/' + next, id] : ['/' + next]; + void this.router.navigate(segments, { queryParamsHandling: 'preserve' }); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx nx test chat-angular-example -- --testNamePattern="mode change preserves"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts \ + examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +git commit -m "feat(demo-shell): preserve thread + query params on mode change" +``` + +--- + +## Task 5: Knob query-param writes via `buildQueryParams()` + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Test: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` + +- [ ] **Step 1: Write a failing test — non-default value lands in URL** + +Append: + +```ts +describe('DemoShell — knob URL writes', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('writes ?model=gpt-5-nano when model changes off default', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onModelChange: (m: string) => void }; + cmp.onModelChange('gpt-5-nano'); + await fx.whenStable(); + expect(router.url).toBe('/embed?model=gpt-5-nano'); + }); + + it('omits ?model when changing back to the default', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?model=gpt-5-nano'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onModelChange: (m: string) => void }; + cmp.onModelChange('gpt-5-mini'); + await fx.whenStable(); + expect(router.url).toBe('/embed'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test chat-angular-example -- --testNamePattern="knob URL writes"` +Expected: FAIL — knob handlers don't yet navigate. + +- [ ] **Step 3: Add a `DEFAULTS` table and `buildQueryParams()` helper** + +Near the top of `demo-shell.component.ts`, add: + +```ts +const KNOB_DEFAULTS = { + model: 'gpt-5-mini', + effort: 'minimal', + genui: 'a2ui', + theme: 'default-dark', + color: 'dark', + project: null as string | null, +} as const; +``` + +Add a method on `DemoShell`: + +```ts +private buildQueryParams(overrides: Partial<Record<keyof typeof KNOB_DEFAULTS, string | null>> = {}): + Record<string, string | null> { + const current: Record<keyof typeof KNOB_DEFAULTS, string | null> = { + model: this.model(), + effort: this.effort(), + genui: this.genUiMode(), + theme: this.theme(), + color: this.colorScheme(), + project: this.selectedProjectId(), + }; + const merged = { ...current, ...overrides }; + const params: Record<string, string | null> = {}; + for (const key of Object.keys(KNOB_DEFAULTS) as (keyof typeof KNOB_DEFAULTS)[]) { + const value = merged[key]; + params[key] = value !== null && value !== KNOB_DEFAULTS[key] ? value : null; + } + return params; +} + +private writeKnobsToUrl(overrides: Partial<Record<keyof typeof KNOB_DEFAULTS, string | null>> = {}): void { + void this.router.navigate([], { + queryParams: this.buildQueryParams(overrides), + replaceUrl: true, + }); +} +``` + +- [ ] **Step 4: Wire each knob handler to also write the URL** + +Append `this.writeKnobsToUrl({ <key>: next })` to each handler. Example: + +```ts +onModelChange(next: string): void { + this.model.set(next); + this.persistence.write('model', next); + this.writeKnobsToUrl({ model: next }); +} + +protected onEffortChange(next: string): void { + this.effort.set(next); + this.persistence.write('effort', next); + this.writeKnobsToUrl({ effort: next }); +} + +protected onGenUiModeChange(next: string): void { + this.genUiMode.set(next); + this.persistence.write('genUiMode', next); + this.writeKnobsToUrl({ genui: next }); +} + +protected onThemeChange(next: string): void { + this.theme.set(next); + this.persistence.write('theme', next); + this.writeKnobsToUrl({ theme: next }); +} + +protected onColorSchemeChange(next: 'light' | 'dark' | string): void { + if (next !== 'light' && next !== 'dark') return; + this.colorScheme.set(next); + this.persistence.write('colorScheme', next); + this.writeKnobsToUrl({ color: next }); +} + +protected onProjectSelected(projectId: string): void { + this.selectedProjectId.set(projectId); + this.persistence.write('selectedProjectId', projectId); + this.writeKnobsToUrl({ project: projectId }); +} +``` + +(Note: the auto-sync inside the color-scheme effect that sets `this.theme.set(next)` should also push the URL change. After setting + persisting, add `this.writeKnobsToUrl({ theme: next });` inside that effect branch.) + +- [ ] **Step 5: Run to verify it passes** + +Run: `npx nx test chat-angular-example -- --testNamePattern="knob URL writes"` +Expected: PASS (both cases). + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts \ + examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +git commit -m "feat(demo-shell): write knob state to URL (defaults omitted)" +``` + +--- + +## Task 6: Hydrate knob signals from URL on `NavigationEnd` + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Test: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` + +- [ ] **Step 1: Write a failing test for knob hydration** + +Append: + +```ts +describe('DemoShell — knob hydration from URL', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('hydrates model + effort from query params on mount', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?model=gpt-5-nano&effort=high'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { + model: () => string; + effort: () => string; + }; + expect(cmp.model()).toBe('gpt-5-nano'); + expect(cmp.effort()).toBe('high'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx nx test chat-angular-example -- --testNamePattern="knob hydration"` +Expected: FAIL — `readUrlState()` doesn't yet touch knob signals. + +- [ ] **Step 3: Extend `readUrlState()`** + +```ts +private readUrlState(): void { + let route = this.router.routerState.root; + while (route.firstChild) route = route.firstChild; + const threadId = route.snapshot.paramMap.get('threadId'); + this.threadIdSignal.set(threadId); + + const q = route.snapshot.queryParamMap; + const model = q.get('model'); + if (model !== null) this.model.set(model); + const effort = q.get('effort'); + if (effort !== null) this.effort.set(effort); + const genui = q.get('genui'); + if (genui !== null) this.genUiMode.set(genui); + const theme = q.get('theme'); + if (theme !== null) this.theme.set(theme); + const color = q.get('color'); + if (color === 'light' || color === 'dark') this.colorScheme.set(color); + const project = q.get('project'); + if (project !== null) this.selectedProjectId.set(project); +} +``` + +**Do NOT call `this.persistence.write(...)` here.** Hydration is ephemeral by design. + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx nx test chat-angular-example -- --testNamePattern="knob hydration"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts \ + examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +git commit -m "feat(demo-shell): hydrate knob signals from query params" +``` + +--- + +## Task 7: Lock down ephemeral semantics + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` + +- [ ] **Step 1: Write tests asserting URL hydration does NOT write localStorage** + +Append: + +```ts +import { PalettePersistence } from './palette-persistence.service'; + +describe('DemoShell — ephemeral hydration', () => { + let writes: Array<[string, unknown]>; + + beforeEach(() => { + writes = []; + const fake = { + read: (_key: string) => null, + write: (key: string, value: unknown) => { writes.push([key, value]); }, + }; + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + { provide: PalettePersistence, useValue: fake }, + ], + }); + }); + + it('does NOT write to persistence when knobs hydrate from URL', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed/abc?model=gpt-5-nano&theme=material-dark'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + expect(writes.find(([k]) => k === 'model')).toBeUndefined(); + expect(writes.find(([k]) => k === 'theme')).toBeUndefined(); + expect(writes.find(([k]) => k === 'threadId')).toBeUndefined(); + }); + + it('DOES write to persistence on explicit user action', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onThemeChange: (t: string) => void }; + cmp.onThemeChange('material-dark'); + expect(writes.find(([k, v]) => k === 'theme' && v === 'material-dark')).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `npx nx test chat-angular-example -- --testNamePattern="ephemeral hydration"` +Expected: PASS (the implementation from Tasks 2 + 6 already enforces this — these tests pin the contract). + +- [ ] **Step 3: Audit for stray `persistence.write('threadId', ...)` calls** + +Run: `git grep -n "persistence.write\\('threadId'" examples/chat/angular/` +Expected: zero matches. If any remain, remove them. + +Also run: `git grep -n "persistence.read\\('threadId'" examples/chat/angular/` +Expected: zero matches. + +- [ ] **Step 4: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +git commit -m "test(demo-shell): pin ephemeral URL → signal hydration contract" +``` + +--- + +## Task 8: E2E deep-link smoke + +**Files:** +- Create: `examples/chat/angular/e2e/url-routing.spec.ts` + +- [ ] **Step 1: Check existing e2e setup** + +Run: `ls examples/chat/angular/e2e/` +Expected: One or more existing `.spec.ts` files — pattern-match the import + base-URL setup from one of them. + +- [ ] **Step 2: Write the deep-link spec** + +Create `examples/chat/angular/e2e/url-routing.spec.ts` (adapt imports to match the existing e2e harness): + +```ts +import { test, expect } from '@playwright/test'; + +test('deep link /embed?model=gpt-5-nano selects gpt-5-nano in the model picker', async ({ page }) => { + await page.goto('/embed?model=gpt-5-nano'); + const modelField = page.locator('[data-field="model"]'); + await expect(modelField).toContainText('gpt-5-nano'); +}); + +test('switching mode preserves :threadId and ?model', async ({ page }) => { + await page.goto('/embed?model=gpt-5-nano'); + // Click the "Popup" segmented button. + await page.getByRole('button', { name: 'Popup' }).click(); + await expect(page).toHaveURL(/\/popup(\/[^?]+)?\?model=gpt-5-nano/); +}); +``` + +- [ ] **Step 3: Run the e2e suite** + +Run: `npx nx e2e chat-angular-example` (or the project's standard e2e command). +Expected: PASS — both specs. + +- [ ] **Step 4: Commit** + +```bash +git add examples/chat/angular/e2e/url-routing.spec.ts +git commit -m "test(demo-e2e): deep-link + mode-switch URL preservation" +``` + +--- + +## Task 9: Manual verification + cleanup + +- [ ] **Step 1: Start the dev server** + +Run: `npx nx serve chat-angular-example` + +- [ ] **Step 2: Smoke-test the full matrix in the browser** + +For each, watch the URL and visible state: +1. Visit `/embed` → URL stays bare; defaults visible. +2. Pick `gpt-5-nano` from the model dropdown → URL becomes `/embed?model=gpt-5-nano`. +3. Pick `gpt-5-mini` (default) again → URL drops `?model=`. +4. Send a message → URL becomes `/embed/<id>?model=...` (push, not replace). +5. Click the "Popup" segmented button → URL becomes `/popup/<id>?model=...`. +6. Open in a fresh incognito tab and paste `/embed/<id>?theme=material-dark&color=light` → loads that thread + theme without writing localStorage. Refresh and confirm localStorage still has the visitor's prior theme (or no theme), NOT the shared one. +7. Use browser back/forward → state round-trips. + +- [ ] **Step 3: Final build + lint** + +Run: `npx nx lint chat-angular-example && npx nx test chat-angular-example && npx nx build chat-angular-example` +Expected: All green. + +- [ ] **Step 4: Open a PR** + +```bash +gh pr create --title "feat(demo): URL routing for shareable demo state" --body "$(cat <<'EOF' +## Summary +- Thread id moves to a `:threadId` path segment per mode; knobs round-trip via query params (defaults omitted). +- URL hydration is ephemeral — overrides signals on visit but does NOT write to a recipient's localStorage. +- Drops `threadId` from localStorage entirely. + +Spec: `docs/superpowers/specs/2026-05-20-demo-url-routing-design.md` + +## Test plan +- [ ] Unit tests pass (`nx test chat-angular-example`) +- [ ] E2E deep-link + mode-switch passes (`nx e2e chat-angular-example`) +- [ ] Manual: copy `/embed/<id>?theme=material-dark` to incognito tab → loads correctly, localStorage untouched +- [ ] Manual: switch mode preserves thread + knobs +- [ ] Manual: change knob to default → param drops from URL + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` From 676d8029ad4200e79cbd53b6dc32b2730a8a8d18 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 09:51:44 -0700 Subject: [PATCH 04/12] feat(demo-routes): add :threadId sibling routes for each mode --- examples/chat/angular/src/app/app.routes.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/chat/angular/src/app/app.routes.ts b/examples/chat/angular/src/app/app.routes.ts index 1c7ebcfaa..e5cead444 100644 --- a/examples/chat/angular/src/app/app.routes.ts +++ b/examples/chat/angular/src/app/app.routes.ts @@ -13,16 +13,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' }, From 14a83fefdc8e8d1171ab9bf27d3e7c5a2272a518 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 10:15:47 -0700 Subject: [PATCH 05/12] feat(demo-shell): hydrate threadIdSignal from route param Drop the localStorage init read; threadIdSignal now starts as null and is set by readUrlState() on mount and on every NavigationEnd, reading the :threadId param from the active leaf route. Also fix pre-existing LANGGRAPH_THREADS_CONFIG provider missing from all spec beforeEach blocks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../app/shell/demo-shell.component.spec.ts | 44 +++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 19 +++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index 6b2b6b1a5..98e1d44bf 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -1,12 +1,19 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; +import { LANGGRAPH_THREADS_CONFIG } from '@ngaf/langgraph'; import { DemoShell } from './demo-shell.component'; +const THREADS_CONFIG = { + provide: LANGGRAPH_THREADS_CONFIG, + useValue: { apiUrl: 'http://localhost:2024', titleMetadataKey: 'title' }, +}; + describe('DemoShell — mode signal', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ + THREADS_CONFIG, provideRouter([ { path: 'embed', component: DemoShell }, { path: 'popup', component: DemoShell }, @@ -48,6 +55,7 @@ describe('DemoShell — toolbar layout', () => { it('no longer renders the "New conversation" button (removed for tightness)', () => { TestBed.configureTestingModule({ providers: [ + THREADS_CONFIG, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -63,6 +71,7 @@ describe('DemoShell — toolbar layout', () => { it('renders fields without visible per-field labels (tighter toolbar)', () => { TestBed.configureTestingModule({ providers: [ + THREADS_CONFIG, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -85,6 +94,7 @@ describe('DemoShell — toolbar dropdowns use chat-select', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ + THREADS_CONFIG, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -105,3 +115,37 @@ describe('DemoShell — toolbar dropdowns use chat-select', () => { } }); }); + +describe('DemoShell — threadId hydration', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + THREADS_CONFIG, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('hydrates threadIdSignal from /embed/:threadId', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed/abc123'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { threadIdSignal: () => string | null }; + expect(cmp.threadIdSignal()).toBe('abc123'); + }); + + it('leaves threadIdSignal null when route has no :threadId', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { threadIdSignal: () => string | null }; + expect(cmp.threadIdSignal()).toBeNull(); + }); +}); 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 8616609b5..d7cd90e79 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -128,6 +128,14 @@ export class DemoShell { this.searchQueryDebounced.set(q); }, 150); }); + + this.readUrlState(); + this.router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(), + ) + .subscribe(() => this.readUrlState()); } protected readonly mode = toSignal( @@ -256,8 +264,8 @@ 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); + /** URL-driven thread id (null when no :threadId param in route). */ + protected readonly threadIdSignal = signal<string | null>(null); /** Title of the currently-selected thread, or 'New chat' if none. The * Python graph writes thread.metadata.title from the first user message @@ -437,6 +445,13 @@ export class DemoShell { } } + private readUrlState(): void { + let route = this.router.routerState.root; + while (route.firstChild) route = route.firstChild; + const threadId = route.snapshot.paramMap.get('threadId'); + this.threadIdSignal.set(threadId); + } + /** * Translates the four-action vocabulary from chat-interrupt-panel * into Command(resume=value) submissions. Phase 3A demo affordance: From f07f841028e6fb566465a530973f7012585c12dd Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 11:14:04 -0700 Subject: [PATCH 06/12] feat(demo-shell): route thread switches/creates through the router Replace direct threadIdSignal.set() + persistence.write('threadId') calls in onThreadSelected, onNewThread, threadActions.delete/archive, and the agent's onThreadId callback with router.navigate(); onThreadId uses replaceUrl: true to avoid extra history entries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../app/shell/demo-shell.component.spec.ts | 29 +++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 18 +++++------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index 98e1d44bf..41bfd8ac4 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -149,3 +149,32 @@ describe('DemoShell — threadId hydration', () => { expect(cmp.threadIdSignal()).toBeNull(); }); }); + +describe('DemoShell — thread switch navigates URL', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + THREADS_CONFIG, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('navigates to /embed/<id> when onThreadSelected fires', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { + onThreadSelected: (id: string) => void; + }; + cmp.onThreadSelected('xyz'); + await fx.whenStable(); + expect(router.url).toBe('/embed/xyz'); + }); +}); 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 d7cd90e79..68ed90cb9 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -302,16 +302,14 @@ export class DemoShell { delete: async (id) => { await this.threadsSvc.delete(id); if (this.threadIdSignal() === id) { - this.threadIdSignal.set(null); - this.persistence.write('threadId', null); + void this.router.navigate(['/' + this.mode()], { queryParamsHandling: 'preserve' }); } }, 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); + void this.router.navigate(['/' + this.mode()], { queryParamsHandling: 'preserve' }); } }, unarchive: (id) => this.threadsSvc.unarchive(id), @@ -334,8 +332,10 @@ export class DemoShell { assistantId: environment.assistantId, threadId: this.threadIdSignal, onThreadId: (id: string) => { - this.threadIdSignal.set(id); - this.persistence.write('threadId', id); + void this.router.navigate(['/' + this.mode(), id], { + queryParamsHandling: 'preserve', + replaceUrl: true, + }); }, // Phase 3B: tells SubagentTracker to treat `research` tool calls as // subagent dispatches and to materialize agent.subagents() from the @@ -415,8 +415,7 @@ 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); + void this.router.navigate(['/' + this.mode(), threadId], { queryParamsHandling: 'preserve' }); } protected onProjectSelected(projectId: string): void { @@ -440,8 +439,7 @@ export class DemoShell { const sel = this.selectedProjectId(); const id = await this.threadsSvc.create(sel ? { projectId: sel } : {}); if (id) { - this.threadIdSignal.set(id); - this.persistence.write('threadId', id); + void this.router.navigate(['/' + this.mode(), id], { queryParamsHandling: 'preserve' }); } } From fe21ee863bb8e40af77dc0f5d8d294b33166aa06 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 14:23:21 -0700 Subject: [PATCH 07/12] feat(demo-shell): preserve thread + query params on mode change --- .../app/shell/demo-shell.component.spec.ts | 29 +++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 4 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index 41bfd8ac4..1f1c6e0f5 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -150,6 +150,35 @@ describe('DemoShell — threadId hydration', () => { }); }); +describe('DemoShell — mode change preserves thread + query', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + THREADS_CONFIG, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: 'popup', component: DemoShell }, + { path: 'popup/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('preserves :threadId and ?model when switching mode', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed/abc?model=gpt-5-nano'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onModeChange: (m: string) => void }; + cmp.onModeChange('popup'); + await fx.whenStable(); + expect(router.url).toBe('/popup/abc?model=gpt-5-nano'); + }); +}); + describe('DemoShell — thread switch navigates URL', () => { beforeEach(() => { TestBed.configureTestingModule({ 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 68ed90cb9..ffe40a181 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -369,7 +369,9 @@ export class DemoShell { })(); protected onModeChange(next: DemoMode | string): void { - void this.router.navigate(['/' + next]); + const id = this.threadIdSignal(); + const segments = id ? ['/' + next, id] : ['/' + next]; + void this.router.navigate(segments, { queryParamsHandling: 'preserve' }); } onModelChange(next: string): void { From 210dba05e3a0fb95aeeef55badb9e5b1fd7303fc Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 14:33:18 -0700 Subject: [PATCH 08/12] feat(demo-shell): write knob state to URL (defaults omitted) Add KNOB_DEFAULTS table and buildQueryParams()/writeKnobsToUrl() helpers. Wire each knob handler (model, effort, genui, theme, color, project) to call writeKnobsToUrl after set+persist; omit params that match defaults so a fresh session shares a bare URL. replaceUrl:true keeps dropdown clicks out of browser history. Also wire the constructor color-scheme auto-sync effect that updates the A2UI theme preset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../app/shell/demo-shell.component.spec.ts | 38 +++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 42 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index 1f1c6e0f5..672071ff2 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -207,3 +207,41 @@ describe('DemoShell — thread switch navigates URL', () => { expect(router.url).toBe('/embed/xyz'); }); }); + +describe('DemoShell — knob URL writes', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + THREADS_CONFIG, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('writes ?model=gpt-5-nano when model changes off default', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onModelChange: (m: string) => void }; + cmp.onModelChange('gpt-5-nano'); + await fx.whenStable(); + expect(router.url).toBe('/embed?model=gpt-5-nano'); + }); + + it('omits ?model when changing back to the default', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?model=gpt-5-nano'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onModelChange: (m: string) => void }; + cmp.onModelChange('gpt-5-mini'); + await fx.whenStable(); + expect(router.url).toBe('/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 ffe40a181..066177773 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -39,6 +39,15 @@ export type DemoMode = 'embed' | 'popup' | 'sidebar'; const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const; const TELEMETRY_SURFACE = 'canonical_demo'; +const KNOB_DEFAULTS = { + model: 'gpt-5-mini', + effort: 'minimal', + genui: 'a2ui', + theme: 'default-dark', + color: 'dark', + project: null as string | null, +} as const; + 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'; @@ -97,6 +106,7 @@ export class DemoShell { if (currentTheme !== next) { this.theme.set(next); this.persistence.write('theme', next); + this.writeKnobsToUrl({ theme: next }); } } }); @@ -377,27 +387,32 @@ export class DemoShell { onModelChange(next: string): void { this.model.set(next); this.persistence.write('model', next); + this.writeKnobsToUrl({ model: next }); } protected onEffortChange(next: string): void { this.effort.set(next); this.persistence.write('effort', next); + this.writeKnobsToUrl({ effort: next }); } protected onGenUiModeChange(next: string): void { this.genUiMode.set(next); this.persistence.write('genUiMode', next); + this.writeKnobsToUrl({ genui: next }); } protected onThemeChange(next: string): void { this.theme.set(next); this.persistence.write('theme', next); + this.writeKnobsToUrl({ theme: next }); } protected onColorSchemeChange(next: 'light' | 'dark' | string): void { if (next !== 'light' && next !== 'dark') return; this.colorScheme.set(next); this.persistence.write('colorScheme', next); + this.writeKnobsToUrl({ color: next }); } protected onSidenavOpenChange(next: boolean): void { @@ -423,6 +438,7 @@ export class DemoShell { protected onProjectSelected(projectId: string): void { this.selectedProjectId.set(projectId); this.persistence.write('selectedProjectId', projectId); + this.writeKnobsToUrl({ project: projectId }); } protected onNewProjectClicked(): void { @@ -445,6 +461,32 @@ export class DemoShell { } } + private buildQueryParams(overrides: Partial<Record<keyof typeof KNOB_DEFAULTS, string | null>> = {}): + Record<string, string | null> { + const current: Record<keyof typeof KNOB_DEFAULTS, string | null> = { + model: this.model(), + effort: this.effort(), + genui: this.genUiMode(), + theme: this.theme(), + color: this.colorScheme(), + project: this.selectedProjectId(), + }; + const merged = { ...current, ...overrides }; + const params: Record<string, string | null> = {}; + for (const key of Object.keys(KNOB_DEFAULTS) as (keyof typeof KNOB_DEFAULTS)[]) { + const value = merged[key]; + params[key] = value !== null && value !== KNOB_DEFAULTS[key] ? value : null; + } + return params; + } + + private writeKnobsToUrl(overrides: Partial<Record<keyof typeof KNOB_DEFAULTS, string | null>> = {}): void { + void this.router.navigate([], { + queryParams: this.buildQueryParams(overrides), + replaceUrl: true, + }); + } + private readUrlState(): void { let route = this.router.routerState.root; while (route.firstChild) route = route.firstChild; From c7d2a3e6922766e7efd5aace917d3b1db7a15464 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 14:39:57 -0700 Subject: [PATCH 09/12] feat(demo-shell): hydrate knob signals from query params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend readUrlState() to read model, effort, genui, theme, color, and project query params on mount and every NavigationEnd. Hydration is ephemeral — no persistence.write() calls inside readUrlState(). Five new tests cover the round-trip. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../app/shell/demo-shell.component.spec.ts | 66 +++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 14 ++++ 2 files changed, 80 insertions(+) diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index 672071ff2..bc8b14892 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -208,6 +208,72 @@ describe('DemoShell — thread switch navigates URL', () => { }); }); +describe('DemoShell — knob hydration from URL', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + THREADS_CONFIG, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('hydrates model + effort from query params on mount', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?model=gpt-5-nano&effort=high'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { + model: () => string; + effort: () => string; + }; + expect(cmp.model()).toBe('gpt-5-nano'); + expect(cmp.effort()).toBe('high'); + }); + + it('hydrates genUiMode from ?genui param', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?genui=json-render'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { genUiMode: () => string }; + expect(cmp.genUiMode()).toBe('json-render'); + }); + + it('hydrates colorScheme from ?color=light', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?color=light'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { colorScheme: () => string }; + expect(cmp.colorScheme()).toBe('light'); + }); + + it('ignores invalid ?color values', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?color=purple'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { colorScheme: () => string }; + // Default is 'dark' — invalid value must not override it + expect(cmp.colorScheme()).toBe('dark'); + }); + + it('hydrates selectedProjectId from ?project param', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?project=proj-42'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { selectedProjectId: () => string | null }; + expect(cmp.selectedProjectId()).toBe('proj-42'); + }); +}); + describe('DemoShell — knob URL writes', () => { beforeEach(() => { TestBed.configureTestingModule({ 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 066177773..3a1aefd30 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -492,6 +492,20 @@ export class DemoShell { while (route.firstChild) route = route.firstChild; const threadId = route.snapshot.paramMap.get('threadId'); this.threadIdSignal.set(threadId); + + const q = route.snapshot.queryParamMap; + const model = q.get('model'); + if (model !== null) this.model.set(model); + const effort = q.get('effort'); + if (effort !== null) this.effort.set(effort); + const genui = q.get('genui'); + if (genui !== null) this.genUiMode.set(genui); + const theme = q.get('theme'); + if (theme !== null) this.theme.set(theme); + const color = q.get('color'); + if (color === 'light' || color === 'dark') this.colorScheme.set(color); + const project = q.get('project'); + if (project !== null) this.selectedProjectId.set(project); } /** From 53f9b685327ab8abc0fe72ff3e9673a8c77c2006 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 14:48:31 -0700 Subject: [PATCH 10/12] =?UTF-8?q?test(demo-shell):=20pin=20ephemeral=20URL?= =?UTF-8?q?=20=E2=86=92=20signal=20hydration=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../app/shell/demo-shell.component.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index bc8b14892..6e9d64ad8 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { LANGGRAPH_THREADS_CONFIG } from '@ngaf/langgraph'; import { DemoShell } from './demo-shell.component'; +import { PalettePersistence } from './palette-persistence.service'; const THREADS_CONFIG = { provide: LANGGRAPH_THREADS_CONFIG, @@ -311,3 +312,47 @@ describe('DemoShell — knob URL writes', () => { expect(router.url).toBe('/embed'); }); }); + +describe('DemoShell — ephemeral hydration', () => { + let writes: Array<[string, unknown]>; + + beforeEach(() => { + writes = []; + const fake = { + read: (_key: string) => null, + write: (key: string, value: unknown) => { writes.push([key, value]); }, + }; + TestBed.configureTestingModule({ + providers: [ + THREADS_CONFIG, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + { provide: PalettePersistence, useValue: fake }, + ], + }); + }); + + it('does NOT write to persistence when knobs hydrate from URL', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed/abc?model=gpt-5-nano&theme=material-dark'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + expect(writes.find(([k]) => k === 'model')).toBeUndefined(); + expect(writes.find(([k]) => k === 'theme')).toBeUndefined(); + expect(writes.find(([k]) => k === 'threadId')).toBeUndefined(); + }); + + it('DOES write to persistence on explicit user action', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const cmp = fx.componentInstance as unknown as { onThemeChange: (t: string) => void }; + cmp.onThemeChange('material-dark'); + expect(writes.find(([k, v]) => k === 'theme' && v === 'material-dark')).toBeDefined(); + }); +}); From 66467ebd65773bf797b698c6499141aa22a7bc23 Mon Sep 17 00:00:00 2001 From: Brian Love <brian@liveloveapp.com> Date: Wed, 20 May 2026 14:51:54 -0700 Subject: [PATCH 11/12] test(demo-e2e): deep-link + mode-switch URL preservation --- examples/chat/angular/e2e/url-routing.spec.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 examples/chat/angular/e2e/url-routing.spec.ts diff --git a/examples/chat/angular/e2e/url-routing.spec.ts b/examples/chat/angular/e2e/url-routing.spec.ts new file mode 100644 index 000000000..16ada70e9 --- /dev/null +++ b/examples/chat/angular/e2e/url-routing.spec.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { toolbarSelect } from './test-helpers'; + +/** + * URL routing smoke tests — deep-link hydration and mode-switch URL preservation. + * + * These specs exercise the two behaviours added by the url-routing-demo branch: + * 1. Query-param hydration: ?model=<value> applied to the URL on first load + * should populate the model picker without any user interaction. + * 2. Mode-switch preservation: clicking a mode button carries the current + * ?model query param into the new route so the selection is not lost. + * + * No aimock / backend needed — both specs only assert DOM state and the browser + * URL; they do not submit any messages. + */ + +test('deep link ?model=gpt-5-nano selects gpt-5-nano in the model picker', async ({ + page, +}) => { + // Navigate directly so the query param is processed by readUrlState(). + // Do NOT use openDemo() here — it clears storage then re-navigates to the + // bare path, stripping the query string we need to test. + await page.goto('/embed?model=gpt-5-nano'); + + const modelTrigger = toolbarSelect(page, 'Model'); + await expect(modelTrigger).toContainText('gpt-5-nano'); +}); + +test('switching mode preserves ?model query param', async ({ page }) => { + await page.goto('/embed?model=gpt-5-nano'); + + // Confirm the picker hydrated correctly before we switch modes. + await expect(toolbarSelect(page, 'Model')).toContainText('gpt-5-nano'); + + // Click the Popup mode button (onModeChange uses queryParamsHandling:'preserve'). + await page.locator('.demo-shell__segmented-button', { hasText: 'Popup' }).click(); + + // The route should now be /popup (optionally with a :threadId segment) and + // ?model=gpt-5-nano must still be present in the URL. + await expect(page).toHaveURL(/\/popup(\/[^?]+)?\?.*model=gpt-5-nano/); + + // The toolbar in popup mode should still show the hydrated selection. + await expect(toolbarSelect(page, 'Model')).toContainText('gpt-5-nano'); +}); From 9ae766ee1bf09a64897bab3dfb06da7cf412359a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 05:01:47 +0000 Subject: [PATCH 12/12] chore(docs): regenerate api docs --- apps/website/content/docs/agent/api/api-docs.json | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index a3132bf8d..61d9f6165 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -296,19 +296,6 @@ } ] }, - { - "name": "getThread", - "signature": "getThread(threadId: string)", - "description": "Fetch a single thread by id. Returns `null` when the server\n returns 404 (thread doesn't exist) so callers can distinguish\n \"missing\" from \"couldn't reach the server\" — genuine network\n errors rethrow. Used by URL-based thread routing to validate a\n pasted/shared thread id before activating it.", - "params": [ - { - "name": "threadId", - "type": "string", - "description": "", - "optional": false - } - ] - }, { "name": "moveToProject", "signature": "moveToProject(threadId: string, projectId: string | null)",