diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index a3132bf8..61d9f616 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)", 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 00000000..4688ee40 --- /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(this.persistence.read('threadId') ?? null); +``` + +with: + +```ts +protected readonly threadIdSignal = signal(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/ 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 { + 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 { + const current: Record = { + 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 = {}; + 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> = {}): 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({ : 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/?model=...` (push, not replace). +5. Click the "Popup" segmented button → URL becomes `/popup/?model=...`. +6. Open in a fresh incognito tab and paste `/embed/?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/?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 +)" +``` 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 00000000..67e69c1d --- /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** (`//`) — 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 + +``` +/[/][?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/` — 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(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: , 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(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/` 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) 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 4bd4c7fb..00000000 --- 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/e2e/url-routing.spec.ts b/examples/chat/angular/e2e/url-routing.spec.ts new file mode 100644 index 00000000..16ada70e --- /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'); +}); diff --git a/examples/chat/angular/src/app/app.routes.ts b/examples/chat/angular/src/app/app.routes.ts index 799e5dd1..e5cead44 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' }, { 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 6b2b6b1a..6e9d64ad 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,20 @@ 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'; +import { PalettePersistence } from './palette-persistence.service'; + +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 +56,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 +72,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 +95,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 +116,243 @@ 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(); + }); +}); + +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({ + 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'); + }); +}); + +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({ + 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'); + }); +}); + +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(); + }); +}); 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 da9bed0e..3a1aefd3 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,18 @@ 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 }; +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'; } @Component({ @@ -102,6 +106,7 @@ export class DemoShell { if (currentTheme !== next) { this.theme.set(next); this.persistence.write('theme', next); + this.writeKnobsToUrl({ theme: next }); } } }); @@ -114,46 +119,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 @@ -173,24 +138,26 @@ 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()); } - /** 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 +274,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); + /** 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 @@ -347,12 +311,16 @@ 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) { + 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); + if (this.threadIdSignal() === id) { + void this.router.navigate(['/' + this.mode()], { queryParamsHandling: 'preserve' }); + } }, unarchive: (id) => this.threadsSvc.unarchive(id), pin: (id) => this.threadsSvc.pin(id), @@ -374,10 +342,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); + 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 @@ -411,47 +379,40 @@ 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 }); + const segments = id ? ['/' + next, id] : ['/' + next]; + void this.router.navigate(segments, { queryParamsHandling: 'preserve' }); } 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 { @@ -471,12 +432,13 @@ export class DemoShell { /** Switch to an existing thread selected from the threads panel. */ protected onThreadSelected(threadId: string): void { - this.threadIdSignal.set(threadId); + void this.router.navigate(['/' + this.mode(), threadId], { queryParamsHandling: 'preserve' }); } protected onProjectSelected(projectId: string): void { this.selectedProjectId.set(projectId); this.persistence.write('selectedProjectId', projectId); + this.writeKnobsToUrl({ project: projectId }); } protected onNewProjectClicked(): void { @@ -494,7 +456,56 @@ 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) { + void this.router.navigate(['/' + this.mode(), id], { queryParamsHandling: 'preserve' }); + } + } + + 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; + 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); } /** diff --git a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts index 232ac3ce..46c46532 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 aad8321a..6771c91c 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 });