From 1bd2a8b13cb95ad73557155ea0fa6147fc70e57d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 14:40:22 -0700 Subject: [PATCH 1/8] docs(spec): demo URL knob query-param round-trip --- .../specs/2026-05-21-demo-url-knobs-design.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md diff --git a/docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md b/docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md new file mode 100644 index 00000000..900b1b8d --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md @@ -0,0 +1,211 @@ +# Demo URL knob round-trip — Design + +**Status:** Approved (scope adapted from closed PR #494) +**Date:** 2026-05-21 +**Goal:** Round-trip the canonical demo's agent knobs (model, reasoning effort, gen-UI mode, theme, color scheme, selected project) through the URL alongside the already-shipped thread id path segment. Ephemeral semantics — URL writes signals on visit but never writes to recipient localStorage. + +## Why now + +URL routing for the thread id landed (PR #500 → #504 → #518). Sharing `/embed/` lands on that thread but defaults for everything else. With knob query params, a visitor who customized model/theme/etc. can share their exact view as a link. + +## Relationship to recent PRs + +- ✅ PR #500: thread id in URL path + `getThread()` validator +- ✅ PR #504: `UrlMatcher` collapse — preserves mode-component instance across `` ↔ `/:threadId` +- ✅ PR #518: localStorage `threadId` persistence removed; URL is sole source of truth for active thread + +This spec **builds on top of** those — does not regress any. UrlMatcher stays. `getThread()` stays. URL-as-truth for `threadId` stays. + +## Scope + +This PR adds: +1. **Knob query param round-trip** (model, effort, genui, theme, color, project) +2. **Ephemeral hydration semantics** — URL writes signals but NOT localStorage +3. **Deep-link e2e spec** — Playwright assertions for `/embed/?model=...` direct loads + +## URL shape + +``` +/[/][?model=&effort=&genui=&theme=&color=&project=] +``` + +Examples (additions vs current behavior in bold): +- `/embed` — fresh demo, all defaults +- `/embed/019e434c-...` — that thread, defaults +- **`/embed/019e434c-...?model=gpt-5-nano&effort=high`** — thread + non-default knobs +- **`/popup/abc?theme=material-dark&color=light`** — full state, popup mode +- **`/sidebar?theme=material-dark`** — no thread yet, custom theme + +**Default values are omitted from URL.** Shared URLs stay short for unchanged knobs. The defaults table: + +| Param | Default | Signal in DemoShell | +|---|---|---| +| `model` | `gpt-5-mini` | `model` | +| `effort` | `minimal` | `effort` | +| `genui` | `a2ui` | `genUiMode` | +| `theme` | `default-dark` | `theme` | +| `color` | `dark` | `colorScheme` | +| `project` | `null` (omitted) | `selectedProjectId` | + +## Architecture + +### Routes — no change required + +The `UrlMatcher` factory shipped in #504 (`app.routes.ts`) already consumes `` and `/` under a single entry per mode. Query params are orthogonal to path matching — Angular reads them via `ActivatedRoute.queryParamMap` regardless of route shape. **No changes to `app.routes.ts`.** + +### URL → signal hydration (new bridge in DemoShell) + +A new private method `hydrateFromQuery()` runs once on `DemoShell` construction and again on every `NavigationEnd`: + +```ts +private hydrateFromQuery(): void { + const params = new URL(this.router.url, 'http://x').searchParams; + + const knobs = [ + ['model', this.model], + ['effort', this.effort], + ['genui', this.genUiMode], + ['theme', this.theme], + ['color', this.colorScheme], + ['project', this.selectedProjectId], + ] as const; + + for (const [key, signal] of knobs) { + const urlValue = params.get(key); + if (urlValue !== null && urlValue !== signal()) { + // Ephemeral: set the signal, do NOT call persistence.write(). + (signal as { set(v: string): void }).set(urlValue); + } + } +} +``` + +Wired via the existing NavigationEnd subscription that drives `urlState`: + +```ts +// In constructor, alongside the URL→threadId sync effect: +effect(() => { + void this.urlState(); // trigger on NavigationEnd + untracked(() => this.hydrateFromQuery()); +}); +``` + +**Why use `URL(router.url).searchParams` and not `ActivatedRoute.queryParamMap`?** ActivatedRoute requires injection plumbing across the route tree; we already parse `router.url` for the mode/threadId via `parseUrl()`. Same approach for query params keeps the bridge in one place. + +### Signal → URL writes (new navigation calls in knob handlers) + +Each knob handler gains a `writeKnobsToUrl()` call after the existing `persistence.write(...)`: + +```ts +protected onModelChange(next: string): void { + this.model.set(next); + this.persistence.write('model', next); + this.writeKnobsToUrl(); +} +``` + +`writeKnobsToUrl()` builds the full query-params object (every knob mapped to either its non-default value or `null`) and calls: + +```ts +private writeKnobsToUrl(): void { + const queryParams = this.buildQueryParams(); + void this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams, + queryParamsHandling: 'merge', + replaceUrl: true, + }); +} + +private buildQueryParams(): Record { + return { + model: this.model() === 'gpt-5-mini' ? null : this.model(), + effort: this.effort() === 'minimal' ? null : this.effort(), + genui: this.genUiMode() === 'a2ui' ? null : this.genUiMode(), + theme: this.theme() === 'default-dark' ? null : this.theme(), + color: this.colorScheme() === 'dark' ? null : this.colorScheme(), + project: this.selectedProjectId() ?? null, + }; +} +``` + +`queryParamsHandling: 'merge'` + nulls drop default keys from the URL automatically. `replaceUrl: true` so dropdown clicks don't pollute browser history. + +### Mode-switch preserves query params + +The existing `onModeChange` (line 418-423) only navigates path segments. Update to also pass query params: + +```ts +protected onModeChange(next: DemoMode | string): void { + const id = this.threadIdSignal(); + void this.router.navigate(id ? ['/', next, id] : ['/', next], { + queryParamsHandling: 'preserve', // ← new + }); +} +``` + +### Thread switch (already-existing signal→URL effect) — no change + +The current signal→URL effect (line 153-159) calls `router.navigate(['/', mode, sigId])` without `queryParamsHandling`, which DROPS query params on thread switch. Update to preserve: + +```ts +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[], { queryParamsHandling: 'preserve' }); +}); +``` + +## Files touched + +| File | Change | +|---|---| +| `examples/chat/angular/src/app/shell/demo-shell.component.ts` | `hydrateFromQuery()` + `writeKnobsToUrl()` + `buildQueryParams()`; 6 knob handlers gain a `writeKnobsToUrl()` call; `onModeChange` + the signal→URL effect gain `queryParamsHandling: 'preserve'`. | +| `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` | New tests (see below). | +| `examples/chat/angular/e2e/url-routing.spec.ts` | NEW file: deep-link e2e. | + +No library changes. No app.routes.ts changes. No palette-persistence.service.ts changes. + +## Testing + +### Unit tests (in `demo-shell.component.spec.ts`) + +1. **Knob hydration from query params** — navigate to `/embed?model=gpt-5-nano&effort=high` → assert `model() === 'gpt-5-nano'` and `effort() === 'high'`. +2. **Ephemeral hydration** — navigate to `/embed?theme=material-dark`; assert localStorage was NOT written (read back the palette JSON, confirm `theme` is absent or unchanged). +3. **Default values omitted from URL** — call `onModelChange('gpt-5-mini')` (the default) → assert URL has no `model=` param. +4. **Non-default values appear in URL** — call `onModelChange('gpt-5-nano')` → assert URL contains `?model=gpt-5-nano`. +5. **Mode change preserves query params** — at `/embed/abc?model=gpt-5-nano`, call `onModeChange('popup')` → URL becomes `/popup/abc?model=gpt-5-nano`. +6. **Thread switch preserves query params** — at `/embed?model=gpt-5-nano`, set `threadIdSignal` to `'xyz'` → URL becomes `/embed/xyz?model=gpt-5-nano` (signal→URL effect path). +7. **User knob action persists** — call `onThemeChange('material-dark')` → assert `persistence.write('theme', 'material-dark')` was called (existing behavior, regression guard). + +### E2e tests (new file `url-routing.spec.ts`) + +1. **Deep link with thread id** — `page.goto('/embed/')` → wait for the thread's existing assistant message to render. +2. **Deep link with knob** — `page.goto('/embed?model=gpt-5-nano')` → assert the model picker reads "gpt-5-nano". +3. **Mode switch preserves both thread + knobs** — From `/embed/?model=gpt-5-nano`, click the Popup mode button → URL is `/popup/?model=gpt-5-nano`. +4. **Ephemeral hydration** — Open `/embed?theme=material-dark` in fresh context → close → reopen `/embed` (no query) → theme is back to default (NOT material-dark from a stale localStorage write). + +## Out of scope + +- A visible "Copy link" UI button — the URL is the link, copy from address bar. +- OG tags / SSR for social previews. +- Auth or read-only modes for shared threads. +- URL state for sub-controls inside chat-input. +- Cross-tab synchronization. +- A migration path that reads old `localStorage.threadId` on first load — already not relevant post-#518. +- New persistence keys; the `PalettePersistence` shape is unchanged. + +## Risks + +- **Navigation loops**: knob → URL writes call `router.navigate` which fires NavigationEnd which triggers `hydrateFromQuery()` which sets signals. Mitigation: the URL→signal write has a compare-and-set guard (`urlValue !== signal()`), so identical values are no-op. The signal→URL effect uses `replaceUrl: true` so even if a loop existed it wouldn't pollute history. +- **Stamp-in-progress for knobs**: agent-allocated thread id has a stamp-in-progress window; knobs don't have an equivalent because there's no async callback that writes them. Not a concern. +- **`queryParamsHandling: 'merge'` vs. removing knob from URL**: setting a knob to `null` in the params object correctly drops it from the URL when using `merge`. Verified in tests #3 and #5. + +## References + +- Current `demo-shell.component.ts:125-159` — URL↔threadId sync block; knob bridge slots in alongside. +- Current `palette-persistence.service.ts:6-16` — `PaletteState` shape (unchanged). +- Current `app.routes.ts:18-30` — UrlMatcher factory (unchanged). +- Closed PR #494 — original broader scope; this spec subsets it to the still-needed bits. From 5cba8de5e9a9241c037fc532bc788cc04bf1b7c9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 14:41:34 -0700 Subject: [PATCH 2/8] docs(spec): add Chrome MCP verification steps to demo URL knobs spec --- .../specs/2026-05-21-demo-url-knobs-design.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md b/docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md index 900b1b8d..6e9f50b8 100644 --- a/docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md +++ b/docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md @@ -187,6 +187,26 @@ No library changes. No app.routes.ts changes. No palette-persistence.service.ts 3. **Mode switch preserves both thread + knobs** — From `/embed/?model=gpt-5-nano`, click the Popup mode button → URL is `/popup/?model=gpt-5-nano`. 4. **Ephemeral hydration** — Open `/embed?theme=material-dark` in fresh context → close → reopen `/embed` (no query) → theme is back to default (NOT material-dark from a stale localStorage write). +### Local Chrome MCP verification (manual, pre-merge) + +Before opening the PR, drive the running dev server with the `mcp__Claude_in_Chrome__*` tools to confirm the behavior in a real browser against a live LangGraph backend (not aimock). Catches regressions that pure-Playwright + aimock can miss (e.g. real `localStorage` write ordering, real router back/forward stacks, real theme repaint). + +Setup: +``` +npx nx serve examples-chat-angular # boots :4200 against shared-dev LangGraph +``` + +Verification steps (each uses `mcp__Claude_in_Chrome__navigate` + `mcp__Claude_in_Chrome__javascript_tool` for assertions): + +1. **Default URL stays clean.** Navigate to `http://localhost:4200/embed`. Assert `window.location.search === ''` (no spurious knob params on a default load). +2. **Knob change writes to URL.** Open the Model dropdown, select `gpt-5-nano`. Assert URL becomes `/embed?model=gpt-5-nano`. Then select `gpt-5-mini` (default) → assert URL drops the `model=` param. +3. **Deep-link sets the picker.** Navigate to `/embed?model=gpt-5-nano&theme=material-dark`. Read the model picker's selected option and the document's `data-theme` attribute; both should reflect the URL values. +4. **Ephemeral hydration.** Clear localStorage. Navigate to `/embed?theme=material-dark`. Assert `JSON.parse(localStorage.getItem('ngaf-chat-demo:palette'))` does NOT contain `theme: 'material-dark'` — the URL hydrated the signal but did not write to storage. +5. **User action persists.** With localStorage still clear, click the theme dropdown and select `material-dark` via the UI. Assert `JSON.parse(localStorage.getItem('ngaf-chat-demo:palette')).theme === 'material-dark'`. +6. **Mode + knob preservation.** Navigate to `/embed/?model=gpt-5-nano`. Click the Popup mode segmented control. Assert URL is `/popup/?model=gpt-5-nano` (thread + knob both preserved). Use browser back; URL returns to `/embed/?model=gpt-5-nano`. + +Each step is verified inline with `mcp__Claude_in_Chrome__javascript_tool` running a small assertion expression; failures stop the verification flow and feed back into the implementation loop. + ## Out of scope - A visible "Copy link" UI button — the URL is the link, copy from address bar. From fb9d444da34a4a2a0821f4236d731c557277192c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 14:45:44 -0700 Subject: [PATCH 3/8] docs(plan): implementation plan for demo URL knob round-trip --- .../plans/2026-05-21-demo-url-knobs.md | 799 ++++++++++++++++++ 1 file changed, 799 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-demo-url-knobs.md diff --git a/docs/superpowers/plans/2026-05-21-demo-url-knobs.md b/docs/superpowers/plans/2026-05-21-demo-url-knobs.md new file mode 100644 index 00000000..1af0e890 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-demo-url-knobs.md @@ -0,0 +1,799 @@ +# Demo URL knob round-trip 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:** Round-trip the demo's agent knobs (model, effort, genui, theme, color, project) through the URL with ephemeral hydration semantics, on top of the URL-as-truth thread-id work already on main. + +**Architecture:** Add three private methods to `DemoShell` — `hydrateFromQuery()` (URL→signal bridge, called via a NavigationEnd-driven effect; never writes localStorage), `writeKnobsToUrl()` (signal→URL bridge, called by each knob handler), and `buildQueryParams()` (defaults→null mapping that drops default values from the URL). Update `onModeChange` + the existing signal→URL thread-switch effect to pass `queryParamsHandling: 'preserve'`. No app.routes.ts changes — UrlMatcher already handles the path shape. + +**Tech Stack:** Angular 22, Angular Router (`Router`, `ActivatedRoute`), Angular signals (`signal`, `effect`, `untracked`), `@nx/vitest:test` for unit tests, `@nx/playwright:playwright` for e2e, `mcp__Claude_in_Chrome__*` for local browser verification. + +--- + +### Task 1: URL → signal hydration (`hydrateFromQuery`) + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts:125-134` (add new effect alongside the URL→threadId effect) and add a new private method +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` (add 2 tests at end of `DemoShell — URL thread sync` block or in a new `DemoShell — URL knob hydration` block) + +- [ ] **Step 1: Write the failing test — knob hydration from query params** + +Append to `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts`: + +```ts +describe('DemoShell — URL knob hydration', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + threadsAdapterProvider, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('hydrates knob signals from URL query params', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?model=gpt-5-nano&effort=high&theme=material-dark'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + model: () => string; + effort: () => string; + theme: () => string; + }; + expect(cmp.model()).toBe('gpt-5-nano'); + expect(cmp.effort()).toBe('high'); + expect(cmp.theme()).toBe('material-dark'); + }); + + it('does NOT write to localStorage when hydrating from URL (ephemeral semantics)', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?theme=material-dark'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const raw = localStorage.getItem('ngaf-chat-demo:palette'); + const stored = raw ? JSON.parse(raw) : {}; + expect(stored.theme).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify both fail** + +Run: `npx nx test examples-chat-angular` + +Expected: 2 new failures in `DemoShell — URL knob hydration` block — `cmp.model()` returns `'gpt-5-mini'` (default), `theme()` returns `'default-dark'` (default). The other 25 tests still pass. + +- [ ] **Step 3: Implement `hydrateFromQuery()`** + +In `examples/chat/angular/src/app/shell/demo-shell.component.ts`, add a new private method (anywhere after the constructor block; the existing `validateUrlThreadId` method at ~line 436 is a good neighbor): + +```ts +/** URL → signal bridge for agent knobs. Fires on every NavigationEnd + * via the constructor effect. Sets each knob signal to its URL value + * iff present and different. NEVER writes to persistence — that's + * the "ephemeral hydration" contract: shared links override signals + * but don't clobber a recipient's localStorage. Explicit user + * actions (onModelChange etc.) still persist via persistence.write. + * + * Explicit per-knob blocks (not a typed loop) because `colorScheme` + * is constrained to `'light' | 'dark'` — a generic loop would need + * ugly casts or runtime any. */ +private hydrateFromQuery(): void { + const params = new URL(this.router.url, 'http://x').searchParams; + + const model = params.get('model'); + if (model !== null && model !== this.model()) this.model.set(model); + + const effort = params.get('effort'); + if (effort !== null && effort !== this.effort()) this.effort.set(effort); + + const genui = params.get('genui'); + if (genui !== null && genui !== this.genUiMode()) this.genUiMode.set(genui); + + const theme = params.get('theme'); + if (theme !== null && theme !== this.theme()) this.theme.set(theme); + + const color = params.get('color'); + if ((color === 'light' || color === 'dark') && color !== this.colorScheme()) { + this.colorScheme.set(color); + } + + const project = params.get('project'); + if (project !== null && project !== this.selectedProjectId()) { + this.selectedProjectId.set(project); + } +} +``` + +- [ ] **Step 4: Wire the hydration effect** + +In the constructor, immediately after the existing URL→threadId effect (the one at ~line 130-134 that ends with `this.threadIdSignal.set(urlId);`), add: + +```ts +// URL → knob signals. Tracks urlState() so it re-fires on every +// NavigationEnd (mode changes, query-param-only navigations both +// emit). hydrateFromQuery is untracked-called because it reads +// every knob signal and we don't want this effect to retrigger +// itself when it writes them. +effect(() => { + void this.urlState(); + untracked(() => this.hydrateFromQuery()); +}); +``` + +- [ ] **Step 5: Run tests, verify both pass** + +Run: `npx nx test examples-chat-angular` + +Expected: 27/27 passing. If "hydrates knob signals from URL query params" still fails, double-check that `hydrateFromQuery()` is being called — add a `console.log` temporarily to confirm. If "does NOT write to localStorage" fails, check that no `persistence.write(...)` was added inside `hydrateFromQuery`. + +- [ ] **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(examples-chat): hydrate knob signals from URL query params + +Adds hydrateFromQuery() private method on DemoShell, wired via a +NavigationEnd-driven effect. Six knobs (model, effort, genui, theme, +color, project) are read from query params and set on their signals +when present. + +Ephemeral semantics: URL hydration does NOT write to localStorage. +A recipient of a shared link gets the URL-specified state but their +own persisted preferences remain untouched. Explicit user actions +(via onModelChange etc.) continue to persist." +``` + +--- + +### Task 2: Signal → URL writes (`writeKnobsToUrl`, `buildQueryParams`, knob handler wiring) + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` — add 2 private methods + update 6 knob handlers +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` — 3 new tests + +- [ ] **Step 1: Write the failing tests — defaults dropped, non-default written, user action persists** + +Append to the `DemoShell — URL knob hydration` describe block in `demo-shell.component.spec.ts`: + +```ts + it('drops default knob values from URL on change-to-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(v: string): void; + }; + cmp.onModelChange('gpt-5-mini'); // default + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).not.toContain('model='); + }); + + it('writes non-default knob values to URL on change', 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(v: string): void; + }; + cmp.onModelChange('gpt-5-nano'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).toContain('model=gpt-5-nano'); + }); + + it('user knob action persists to localStorage (regression guard)', 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(v: string): void; + }; + cmp.onThemeChange('material-dark'); + const raw = localStorage.getItem('ngaf-chat-demo:palette'); + const stored = raw ? JSON.parse(raw) : {}; + expect(stored.theme).toBe('material-dark'); + }); +``` + +- [ ] **Step 2: Run tests, verify the 3 new ones fail** + +Run: `npx nx test examples-chat-angular` + +Expected: "drops default knob values…" fails (URL still contains `model=` because we never wrote to it from the existing onModelChange), "writes non-default knob values…" fails (same reason), "user knob action persists…" passes already (existing behavior). The 2 hydration tests from Task 1 still pass. + +- [ ] **Step 3: Implement `buildQueryParams()` + `writeKnobsToUrl()`** + +Add to `demo-shell.component.ts` near `hydrateFromQuery`: + +```ts +/** Build the full knob → URL-value mapping. Default values become + * null so Angular's router drops them from the resulting URL when + * used with queryParamsHandling: 'merge'. */ +private buildQueryParams(): Record { + return { + model: this.model() === 'gpt-5-mini' ? null : this.model(), + effort: this.effort() === 'minimal' ? null : this.effort(), + genui: this.genUiMode() === 'a2ui' ? null : this.genUiMode(), + theme: this.theme() === 'default-dark' ? null : this.theme(), + color: this.colorScheme() === 'dark' ? null : this.colorScheme(), + project: this.selectedProjectId() ?? null, + }; +} + +/** Signal → URL bridge for agent knobs. Called by each knob handler + * after it sets its signal + persistence. Uses queryParamsHandling: + * 'merge' + replaceUrl so dropdown clicks don't pollute history. */ +private writeKnobsToUrl(): void { + void this.router.navigate([], { + queryParams: this.buildQueryParams(), + queryParamsHandling: 'merge', + replaceUrl: true, + }); +} +``` + +- [ ] **Step 4: Wire all 6 knob handlers** + +Update each handler to add a final `this.writeKnobsToUrl()` call: + +```ts +onModelChange(next: string): void { + this.model.set(next); + this.persistence.write('model', next); + this.writeKnobsToUrl(); +} + +protected onEffortChange(next: string): void { + this.effort.set(next); + this.persistence.write('effort', next); + this.writeKnobsToUrl(); +} + +protected onGenUiModeChange(next: string): void { + this.genUiMode.set(next); + this.persistence.write('genUiMode', next); + this.writeKnobsToUrl(); +} + +protected onThemeChange(next: string): void { + this.theme.set(next); + this.persistence.write('theme', next); + this.writeKnobsToUrl(); +} + +protected onColorSchemeChange(next: 'light' | 'dark' | string): void { + if (next !== 'light' && next !== 'dark') return; + this.colorScheme.set(next); + this.persistence.write('colorScheme', next); + this.writeKnobsToUrl(); +} + +protected onProjectSelected(projectId: string): void { + this.selectedProjectId.set(projectId); + this.persistence.write('selectedProjectId', projectId); + this.writeKnobsToUrl(); +} +``` + +Note: `onProjectSelected` previously did not persist. Restore the existing behavior intact — only ADD the `writeKnobsToUrl()` call. Re-read the file at line 491 to confirm the original body and don't drop other lines. + +- [ ] **Step 5: Re-read `onProjectSelected` to verify nothing was clobbered** + +Run: `sed -n '491,500p' examples/chat/angular/src/app/shell/demo-shell.component.ts` + +Expected: the body matches the file before your edit, plus the new `writeKnobsToUrl()` line. If `persistence.write('selectedProjectId', projectId)` did NOT exist before, do NOT add it — the call to `writeKnobsToUrl()` is enough to round-trip the project through the URL. Confirm against the live file. + +- [ ] **Step 6: Run tests, verify all pass** + +Run: `npx nx test examples-chat-angular` + +Expected: 30/30 passing (25 prior + 5 from Tasks 1–2). + +- [ ] **Step 7: 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(examples-chat): write knob signal changes to URL query params + +Adds buildQueryParams() + writeKnobsToUrl() private methods. +Each of the six knob handlers (onModelChange, onEffortChange, +onGenUiModeChange, onThemeChange, onColorSchemeChange, +onProjectSelected) now calls writeKnobsToUrl() after persisting. + +Default values are mapped to null in buildQueryParams() so the +Angular router drops them from the URL with queryParamsHandling: +'merge'. replaceUrl: true so dropdown clicks don't pollute the +browser history." +``` + +--- + +### Task 3: Preserve query params on mode change + thread switch + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` — update `onModeChange` (line ~424) and the signal→URL effect (line ~152-159) +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` — 2 new tests + +- [ ] **Step 1: Write the failing tests — mode and thread preserve query params** + +Append to the `DemoShell — URL knob hydration` describe block: + +```ts + it('preserves query params on mode change', 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 { + onModeChange(next: string): void; + }; + cmp.onModeChange('popup'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).toContain('model=gpt-5-nano'); + }); + + it('preserves query params on thread switch (signal→URL effect)', 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 { + threadIdSignal: { set(v: string | null): void }; + }; + cmp.threadIdSignal.set('xyz123'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).toContain('/embed/xyz123'); + expect(router.url).toContain('model=gpt-5-nano'); + }); +``` + +- [ ] **Step 2: Run tests, verify both fail** + +Run: `npx nx test examples-chat-angular` + +Expected: both fail because `onModeChange` and the signal→URL effect both call `router.navigate(...)` without `queryParamsHandling`, which DROPS query params. The URL becomes `/popup` (no model=) and `/embed/xyz123` (no model=). + +- [ ] **Step 3: Update `onModeChange`** + +In `demo-shell.component.ts`, change `onModeChange` (around line 424) to: + +```ts +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. + // Preserve query params so knob state survives the mode hop. + const id = this.threadIdSignal(); + void this.router.navigate( + id ? ['/', next, id] : ['/', next], + { queryParamsHandling: 'preserve' }, + ); +} +``` + +- [ ] **Step 4: Update the signal → URL effect** + +In `demo-shell.component.ts`, change the signal→URL effect (around lines 152-159) to: + +```ts +// 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). +// Preserves query params so knob state survives the thread hop. +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[], { queryParamsHandling: 'preserve' }); +}); +``` + +- [ ] **Step 5: Run tests, verify all pass** + +Run: `npx nx test examples-chat-angular` + +Expected: 32/32 passing. + +- [ ] **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(examples-chat): preserve query params on mode and thread switch + +Two navigations were dropping knob query params silently: +- onModeChange (e.g. clicking 'Popup' in the segmented control) +- the signal→URL effect that pushes agent-allocated thread ids + +Both now use queryParamsHandling: 'preserve' so the URL's full state +(thread + knobs) survives mode hops and thread switches." +``` + +--- + +### Task 4: Deep-link e2e (`url-routing.spec.ts`) + +**Files:** +- Create: `examples/chat/angular/e2e/url-routing.spec.ts` + +- [ ] **Step 1: Confirm aimock fixtures have a usable seeded thread** + +Run: `ls examples/chat/angular/e2e/fixtures/` + +Expected: includes `hi.json` (the canonical "say hi briefly" fixture used by `sendPromptAndWait`). The e2e cannot pin a specific thread id from a fixture file — fixtures replay LLM responses, not LangGraph thread state. Strategy: first test creates a thread via the chat flow, captures its id, then navigates to `/embed/` to verify deep-link rendering. + +- [ ] **Step 2: Write the e2e spec** + +Create `examples/chat/angular/e2e/url-routing.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { + activeThreadIdFromUrl, + messageInput, + openDemo, + sendButton, + waitForFinalAssistant, +} from './test-helpers'; + +test('url routing: deep-link with thread id loads that thread', async ({ page }) => { + // Bootstrap: create a thread by sending one message. + await openDemo(page, '/embed'); + await messageInput(page).fill('say hi briefly'); + await sendButton(page).click(); + await waitForFinalAssistant(page); + + await expect(page).toHaveURL(/\/embed\/[A-Za-z0-9-]+$/); + const threadId = await activeThreadIdFromUrl(page); + expect(threadId).toBeTruthy(); + + // Reload via direct navigation to /embed/ — assert the existing + // assistant message renders without resending the prompt. + await page.goto(`/embed/${threadId}`); + await expect(page.locator('chat-message[data-role="assistant"]')).toContainText(/hi/i, { + timeout: 30_000, + }); +}); + +test('url routing: deep-link with knob param sets the picker', async ({ page }) => { + await openDemo(page, '/embed?model=gpt-5-nano'); + + // The model toolbar trigger surfaces the current model. Confirm the URL + // value won, not the default. + const modelTrigger = page.locator('.demo-shell__field[data-field="model"] .chat-select__trigger'); + await expect(modelTrigger).toContainText('gpt-5-nano'); +}); + +test('url routing: mode switch preserves thread + knob params', async ({ page }) => { + // Bootstrap: thread + non-default knob. + await openDemo(page, '/embed'); + await messageInput(page).fill('say hi briefly'); + await sendButton(page).click(); + await waitForFinalAssistant(page); + const threadId = await activeThreadIdFromUrl(page); + expect(threadId).toBeTruthy(); + + // Set a non-default model via the toolbar. + const modelTrigger = page.locator('.demo-shell__field[data-field="model"] .chat-select__trigger'); + await modelTrigger.click(); + await page.locator('.chat-select__option', { hasText: 'gpt-5-nano' }).first().click(); + await expect(page).toHaveURL(/[?&]model=gpt-5-nano/); + + // Click Popup mode in the segmented control. + await page.locator('.demo-shell__segmented-button', { hasText: 'Popup' }).click(); + + // URL holds both thread + knob param. + await expect(page).toHaveURL(new RegExp(`/popup/${threadId}(\\?|\\?.*&)model=gpt-5-nano`)); +}); + +test('url routing: ephemeral hydration does not write to localStorage', async ({ page }) => { + // Visit with a non-default theme in the URL. + await openDemo(page, '/embed?theme=material-dark'); + + // openDemo clears localStorage before the test starts; assert it's + // still clean (no `theme: 'material-dark'` written by hydration). + const stored = await page.evaluate(() => { + const raw = localStorage.getItem('ngaf-chat-demo:palette'); + return raw ? (JSON.parse(raw) as { theme?: string }).theme : null; + }); + expect(stored).toBeNull(); +}); +``` + +- [ ] **Step 3: Run the new e2e suite locally (optional fast check)** + +If you have `nx serve examples-chat-angular` infra ready locally, run: + +```bash +npx nx e2e examples-chat-angular -- examples/chat/angular/e2e/url-routing.spec.ts +``` + +Expected: all 4 tests pass in ~30-60s. If a test fails and the failure is about the toolbar selector (`.demo-shell__field[data-field="model"]`), grep the codebase for the actual selector — it may have drifted: + +```bash +grep -rn 'data-field=' examples/chat/angular/src/ | head -5 +``` + +If the selector differs, update the spec to match the codebase. Don't change the codebase to match the spec. + +- [ ] **Step 4: Commit** + +```bash +git add examples/chat/angular/e2e/url-routing.spec.ts +git commit -m "test(examples-chat): add url-routing e2e for knob query params + +Four scenarios: +1. Deep-link /embed/ loads the thread without resending. +2. /embed?model=gpt-5-nano sets the model picker via URL hydration. +3. Mode switch preserves both /embed/ path and ?model= query. +4. Ephemeral hydration: /embed?theme=material-dark does NOT write + theme to localStorage (URL hydrates signal but not storage)." +``` + +--- + +### Task 5: Local Chrome MCP verification + +**Files:** +- None modified; this is a verification gate, not a code task. + +**Purpose:** Drive the running dev server with `mcp__Claude_in_Chrome__*` tools to catch regressions that pure-Playwright + aimock can miss (real localStorage write ordering, real router history stacks, real theme repaints). + +- [ ] **Step 1: Boot the dev server in the background** + +In a separate shell (or via `run_in_background: true` on a Bash call): + +```bash +cd /Users/blove/repos/angular-agent-framework +npx nx serve examples-chat-angular +``` + +Wait until the console reports `➜ Local: http://localhost:4200/`. This may take 30-60s. + +- [ ] **Step 2: Default URL stays clean** + +Use `mcp__Claude_in_Chrome__navigate` to open `http://localhost:4200/embed`, then `mcp__Claude_in_Chrome__javascript_tool` to evaluate: + +```js +window.location.search +``` + +Expected: `""` (empty). If a query string appears on a clean default load, `writeKnobsToUrl` is being called when it shouldn't be (e.g. on initial hydration). + +- [ ] **Step 3: Knob change writes to URL; default drops the param** + +Use `mcp__Claude_in_Chrome__find` to locate the Model dropdown, click it via `mcp__Claude_in_Chrome__left_click` (or whichever click tool is available), select `gpt-5-nano`. + +Then `javascript_tool`: +```js +window.location.search +``` +Expected: contains `model=gpt-5-nano`. + +Then change back to `gpt-5-mini` (the default) and re-evaluate: +```js +window.location.search.includes('model=') +``` +Expected: `false`. + +- [ ] **Step 4: Deep-link sets the picker** + +Navigate to `http://localhost:4200/embed?model=gpt-5-nano&theme=material-dark`. + +`javascript_tool`: +```js +({ + model: document.querySelector('.demo-shell__field[data-field="model"] .chat-select__trigger')?.textContent?.trim(), + theme: document.documentElement.getAttribute('data-theme'), +}) +``` +Expected: `{ model: 'gpt-5-nano', theme: 'material-dark' }`. + +- [ ] **Step 5: Ephemeral hydration** + +`javascript_tool`: +```js +localStorage.clear(); +``` + +Navigate to `/embed?theme=material-dark`. + +`javascript_tool`: +```js +const raw = localStorage.getItem('ngaf-chat-demo:palette'); +raw ? JSON.parse(raw).theme : null +``` +Expected: `null` (URL hydrated the signal but did NOT write to storage). + +- [ ] **Step 6: User action persists** + +With localStorage still clear, click the Theme dropdown in the toolbar and select `material-dark` via the UI. + +`javascript_tool`: +```js +JSON.parse(localStorage.getItem('ngaf-chat-demo:palette')).theme +``` +Expected: `"material-dark"`. + +- [ ] **Step 7: Mode + knob preservation** + +Send a message via the chat input to allocate a thread (or pick a known seeded one from a previous step). Capture the resulting URL — should look like `/embed/?theme=material-dark`. + +Click the Popup mode segmented control. + +`javascript_tool`: +```js +window.location.pathname + window.location.search +``` +Expected: `/popup/?theme=material-dark`. + +Then `mcp__Claude_in_Chrome__javascript_tool`: +```js +window.history.back() +``` + +Re-evaluate: +```js +window.location.pathname + window.location.search +``` +Expected: `/embed/?theme=material-dark`. + +- [ ] **Step 8: Stop the dev server** + +If launched in the background, stop it now (Ctrl-C in the launching terminal, or kill the background bash). + +- [ ] **Step 9: Record verification outcome** + +Add a brief note in the PR description after pushing (Task 6) summarizing the Chrome MCP verification: + +``` +## Chrome MCP verification (local, against `nx serve` + shared-dev LangGraph) + +- ✅ Default URL stays clean (no spurious query params) +- ✅ Knob change writes URL; reset to default drops the param +- ✅ Deep-link sets the model picker + theme attribute +- ✅ URL hydration does NOT write to localStorage +- ✅ User UI action DOES persist to localStorage +- ✅ Mode switch preserves thread + knob, browser back restores +``` + +If any step fails, do NOT proceed to Task 6. Return to the failing implementation, fix it, re-run all relevant unit/e2e tests + Chrome MCP steps. + +--- + +### Task 6: Push, open PR, monitor CI + +**Files:** +- None modified. + +- [ ] **Step 1: Push the branch** + +Run: `git push -u origin claude/demo-url-knobs` + +Expected: push succeeds, branch tracked. + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "feat(examples-chat): round-trip agent knobs through URL query params" --body "$(cat <<'EOF' +## Summary + +Round-trips the demo's six agent knobs (model, effort, genui, theme, color, project) through the URL with ephemeral hydration semantics, on top of the URL-as-truth thread-id work already on main. + +URL shape: +\`\`\` +/[/][?model=&effort=&genui=&theme=&color=&project=] +\`\`\` + +Default values are omitted; non-default values appear; the URL is the share surface. + +Builds on PR #500 + PR #504 + PR #518 — preserves UrlMatcher, getThread() validator, and URL-as-truth threadId semantics. + +## Files changed + +- \`examples/chat/angular/src/app/shell/demo-shell.component.ts\` — 3 new private methods (hydrateFromQuery, writeKnobsToUrl, buildQueryParams) + 6 knob handlers wired to writeKnobsToUrl + onModeChange/signal→URL effect both preserve query params. +- \`examples/chat/angular/src/app/shell/demo-shell.component.spec.ts\` — 7 new unit tests. +- \`examples/chat/angular/e2e/url-routing.spec.ts\` — NEW Playwright spec: 4 deep-link assertions. + +## Test plan + +- [x] Unit: 32/32 passing in \`examples-chat-angular\` +- [ ] e2e matrix: all 4 \`examples/chat — e2e (N/4)\` shards green +- [x] Chrome MCP verification: all 6 manual steps pass (see comment below for outcome) + +Spec: \`docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md\` +Plan: \`docs/superpowers/plans/2026-05-21-demo-url-knobs.md\` + +Supersedes the now-closed PR #494, focused down to just the still-needed bits. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: a URL to the new PR is printed. + +- [ ] **Step 3: Append the Chrome MCP verification note as a PR comment** + +Use the verification outcome captured in Task 5 Step 9. Paste it as a comment on the PR via: + +```bash +PR=$(gh pr view --json number --jq .number) +gh pr comment $PR --body "$(cat <<'EOF' +## Chrome MCP verification (local, against `nx serve` + shared-dev LangGraph) + +- ✅ Default URL stays clean +- ✅ Knob change writes URL; reset to default drops the param +- ✅ Deep-link sets the model picker + theme attribute +- ✅ URL hydration does NOT write to localStorage +- ✅ User UI action DOES persist to localStorage +- ✅ Mode switch preserves thread + knob, browser back restores +EOF +)" +``` + +- [ ] **Step 4: Monitor the first CI run** + +Wait ~1-2 minutes after push, then: + +```bash +gh pr checks $(gh pr view --json number --jq .number) +``` + +Expected: all 4 \`examples/chat — e2e (N/4)\` shards run, plus \`examples/chat — e2e\` summary. If a shard fails, pull the failed test names: + +```bash +RUN=$(gh run list --branch claude/demo-url-knobs --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId') +gh run view $RUN --json jobs --jq '.jobs[] | select(.conclusion=="failure") | .name' +``` + +- [ ] **Step 5: Hand off to user for merge decision** + +The plan ends here. The user decides when to admin-merge. + +--- + +## Verification checklist (entire plan) + +After all tasks, verify against `docs/superpowers/specs/2026-05-21-demo-url-knobs-design.md`: + +- ✅ `hydrateFromQuery` reads 6 knobs from URL via `URL(router.url).searchParams` +- ✅ Hydration NEVER writes to localStorage (regression-tested) +- ✅ `buildQueryParams` maps defaults to null; non-defaults to current value +- ✅ `writeKnobsToUrl` uses `queryParamsHandling: 'merge'` + `replaceUrl: true` +- ✅ All 6 knob handlers call `writeKnobsToUrl` after `persistence.write` +- ✅ `onModeChange` uses `queryParamsHandling: 'preserve'` +- ✅ signal→URL thread-switch effect uses `queryParamsHandling: 'preserve'` +- ✅ `app.routes.ts` unchanged (UrlMatcher preserved) +- ✅ `getThread()` 404 validator unchanged +- ✅ `palette-persistence.service.ts` unchanged +- ✅ Unit tests: hydration, ephemeral, default-dropped, non-default-written, mode-preserves, thread-preserves, user-action-persists +- ✅ E2e tests: deep-link thread, deep-link knob, mode-switch preservation, ephemeral hydration +- ✅ Chrome MCP verification: 6 manual steps pass against live `nx serve` + +If any item is unchecked, return to the task that owns it before requesting review. From fd61941f18ef2af3b7fe1ccb544da71c45d4af31 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 15:26:23 -0700 Subject: [PATCH 4/8] feat(examples-chat): hydrate knob signals from URL query params Adds hydrateFromQuery() private method on DemoShell, wired via a NavigationEnd-driven effect. Six knobs (model, effort, genui, theme, color, project) are read from query params and set on their signals when present. Ephemeral semantics: URL hydration does NOT write to localStorage. A recipient of a shared link gets the URL-specified state but their own persisted preferences remain untouched. Explicit user actions (via onModelChange etc.) continue to persist. --- .../app/shell/demo-shell.component.spec.ts | 44 ++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 46 +++++++++++++++++++ 2 files changed, 90 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 f255c51e..8c9c4920 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 @@ -216,3 +216,47 @@ describe('DemoShell — URL thread sync', () => { expect(cmp.threadIdSignal()).toBe('url-thread'); }); }); + +describe('DemoShell — URL knob hydration', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + threadsAdapterProvider, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'embed/:threadId', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('hydrates knob signals from URL query params', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?model=gpt-5-nano&effort=high&theme=material-dark'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + model: () => string; + effort: () => string; + theme: () => string; + }; + expect(cmp.model()).toBe('gpt-5-nano'); + expect(cmp.effort()).toBe('high'); + expect(cmp.theme()).toBe('material-dark'); + }); + + it('does NOT write to localStorage when hydrating from URL (ephemeral semantics)', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed?theme=material-dark'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const raw = localStorage.getItem('ngaf-chat-demo:palette'); + const stored = raw ? JSON.parse(raw) : {}; + expect(stored.theme).toBeUndefined(); + }); +}); 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 b9d5e9a8..571c1a6c 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -133,6 +133,16 @@ export class DemoShell { } }); + // URL → knob signals. Tracks urlState() so it re-fires on every + // NavigationEnd (mode changes, query-param-only navigations both + // emit). hydrateFromQuery is untracked-called because it reads + // every knob signal and we don't want this effect to retrigger + // itself when it writes them. + effect(() => { + void this.urlState(); + untracked(() => this.hydrateFromQuery()); + }); + // 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 @@ -428,6 +438,42 @@ export class DemoShell { void this.router.navigate(id ? ['/', next, id] : ['/', next]); } + /** URL → signal bridge for agent knobs. Fires on every NavigationEnd + * via the constructor effect. Sets each knob signal to its URL value + * iff present and different. NEVER writes to persistence — that's + * the "ephemeral hydration" contract: shared links override signals + * but don't clobber a recipient's localStorage. Explicit user + * actions (onModelChange etc.) still persist via persistence.write. + * + * Explicit per-knob blocks (not a typed loop) because `colorScheme` + * is constrained to `'light' | 'dark'` — a generic loop would need + * ugly casts or runtime any. */ + private hydrateFromQuery(): void { + const params = new URL(this.router.url, 'http://x').searchParams; + + const model = params.get('model'); + if (model !== null && model !== this.model()) this.model.set(model); + + const effort = params.get('effort'); + if (effort !== null && effort !== this.effort()) this.effort.set(effort); + + const genui = params.get('genui'); + if (genui !== null && genui !== this.genUiMode()) this.genUiMode.set(genui); + + const theme = params.get('theme'); + if (theme !== null && theme !== this.theme()) this.theme.set(theme); + + const color = params.get('color'); + if ((color === 'light' || color === 'dark') && color !== this.colorScheme()) { + this.colorScheme.set(color); + } + + const project = params.get('project'); + if (project !== null && project !== this.selectedProjectId()) { + this.selectedProjectId.set(project); + } + } + /** 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 From beccb7a42ecb0b8e12ef5b5c9b2455d41c17e1c0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 15:29:29 -0700 Subject: [PATCH 5/8] feat(examples-chat): write knob signal changes to URL query params Adds buildQueryParams() + writeKnobsToUrl() private methods. Each of the six knob handlers (onModelChange, onEffortChange, onGenUiModeChange, onThemeChange, onColorSchemeChange, onProjectSelected) now calls writeKnobsToUrl() after persisting. Default values are mapped to null in buildQueryParams() so the Angular router drops them from the URL with queryParamsHandling: 'merge'. replaceUrl: true so dropdown clicks don't pollute the browser history. --- .../app/shell/demo-shell.component.spec.ts | 47 +++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 31 ++++++++++++ 2 files changed, 78 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 8c9c4920..11ce98b3 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 @@ -259,4 +259,51 @@ describe('DemoShell — URL knob hydration', () => { const stored = raw ? JSON.parse(raw) : {}; expect(stored.theme).toBeUndefined(); }); + + it('drops default knob values from URL on change-to-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(v: string): void; + }; + cmp.onModelChange('gpt-5-mini'); // default + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).not.toContain('model='); + }); + + it('writes non-default knob values to URL on change', 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(v: string): void; + }; + cmp.onModelChange('gpt-5-nano'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).toContain('model=gpt-5-nano'); + }); + + it('user knob action persists to localStorage (regression guard)', 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(v: string): void; + }; + cmp.onThemeChange('material-dark'); + const raw = localStorage.getItem('ngaf-chat-demo:palette'); + const stored = raw ? JSON.parse(raw) : {}; + expect(stored.theme).toBe('material-dark'); + }); }); 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 571c1a6c..9b8a8990 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -438,6 +438,31 @@ export class DemoShell { void this.router.navigate(id ? ['/', next, id] : ['/', next]); } + /** Build the full knob → URL-value mapping. Default values become + * null so Angular's router drops them from the resulting URL when + * used with queryParamsHandling: 'merge'. */ + private buildQueryParams(): Record { + return { + model: this.model() === 'gpt-5-mini' ? null : this.model(), + effort: this.effort() === 'minimal' ? null : this.effort(), + genui: this.genUiMode() === 'a2ui' ? null : this.genUiMode(), + theme: this.theme() === 'default-dark' ? null : this.theme(), + color: this.colorScheme() === 'dark' ? null : this.colorScheme(), + project: this.selectedProjectId() ?? null, + }; + } + + /** Signal → URL bridge for agent knobs. Called by each knob handler + * after it sets its signal + persistence. Uses queryParamsHandling: + * 'merge' + replaceUrl so dropdown clicks don't pollute history. */ + private writeKnobsToUrl(): void { + void this.router.navigate([], { + queryParams: this.buildQueryParams(), + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + /** URL → signal bridge for agent knobs. Fires on every NavigationEnd * via the constructor effect. Sets each knob signal to its URL value * iff present and different. NEVER writes to persistence — that's @@ -491,27 +516,32 @@ export class DemoShell { onModelChange(next: string): void { this.model.set(next); this.persistence.write('model', next); + this.writeKnobsToUrl(); } protected onEffortChange(next: string): void { this.effort.set(next); this.persistence.write('effort', next); + this.writeKnobsToUrl(); } protected onGenUiModeChange(next: string): void { this.genUiMode.set(next); this.persistence.write('genUiMode', next); + this.writeKnobsToUrl(); } protected onThemeChange(next: string): void { this.theme.set(next); this.persistence.write('theme', next); + this.writeKnobsToUrl(); } protected onColorSchemeChange(next: 'light' | 'dark' | string): void { if (next !== 'light' && next !== 'dark') return; this.colorScheme.set(next); this.persistence.write('colorScheme', next); + this.writeKnobsToUrl(); } protected onSidenavOpenChange(next: boolean): void { @@ -537,6 +567,7 @@ export class DemoShell { protected onProjectSelected(projectId: string): void { this.selectedProjectId.set(projectId); this.persistence.write('selectedProjectId', projectId); + this.writeKnobsToUrl(); } protected onNewProjectClicked(): void { From 5fdabc5c8f26b590ac66b62e1803135ee6a2f9aa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 15:31:24 -0700 Subject: [PATCH 6/8] feat(examples-chat): preserve query params on mode and thread switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two navigations were dropping knob query params silently: - onModeChange (e.g. clicking 'Popup' in the segmented control) - the signal→URL effect that pushes agent-allocated thread ids Both now use queryParamsHandling: 'preserve' so the URL's full state (thread + knobs) survives mode hops and thread switches. --- .../app/shell/demo-shell.component.spec.ts | 33 +++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 9 +++-- 2 files changed, 40 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 11ce98b3..86b84efc 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 @@ -306,4 +306,37 @@ describe('DemoShell — URL knob hydration', () => { const stored = raw ? JSON.parse(raw) : {}; expect(stored.theme).toBe('material-dark'); }); + + it('preserves query params on mode change', 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 { + onModeChange(next: string): void; + }; + cmp.onModeChange('popup'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).toContain('model=gpt-5-nano'); + }); + + it('preserves query params on thread switch (signal→URL effect)', 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 { + threadIdSignal: { set(v: string | null): void }; + }; + cmp.threadIdSignal.set('xyz123'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(router.url).toContain('/embed/xyz123'); + expect(router.url).toContain('model=gpt-5-nano'); + }); }); 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 9b8a8990..bb016350 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -162,12 +162,13 @@ export class DemoShell { // 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). + // Preserves query params so knob state survives the thread hop. 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[]); + void this.router.navigate(cmds as string[], { queryParamsHandling: 'preserve' }); }); // Refresh threads list when an agent run completes. The backend writes @@ -434,8 +435,12 @@ 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. + // Preserve query params so knob state survives the mode hop. const id = this.threadIdSignal(); - void this.router.navigate(id ? ['/', next, id] : ['/', next]); + void this.router.navigate( + id ? ['/', next, id] : ['/', next], + { queryParamsHandling: 'preserve' }, + ); } /** Build the full knob → URL-value mapping. Default values become From 53de2c7f0d9fbad46a3e92a8b5dfdf0bc4c09fee Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 15:32:43 -0700 Subject: [PATCH 7/8] test(examples-chat): add url-routing e2e for knob query params Four scenarios: 1. Deep-link /embed/ loads the thread without resending. 2. /embed?model=gpt-5-nano sets the model picker via URL hydration. 3. Mode switch preserves both /embed/ path and ?model= query. 4. Ephemeral hydration: /embed?theme=material-dark does NOT write theme to localStorage (URL hydrates signal but not storage). --- examples/chat/angular/e2e/url-routing.spec.ts | 72 +++++++++++++++++++ 1 file changed, 72 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 00000000..bec176e4 --- /dev/null +++ b/examples/chat/angular/e2e/url-routing.spec.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { + activeThreadIdFromUrl, + messageInput, + openDemo, + sendButton, + waitForFinalAssistant, +} from './test-helpers'; + +test('url routing: deep-link with thread id loads that thread', async ({ page }) => { + // Bootstrap: create a thread by sending one message. + await openDemo(page, '/embed'); + await messageInput(page).fill('say hi briefly'); + await sendButton(page).click(); + await waitForFinalAssistant(page); + + await expect(page).toHaveURL(/\/embed\/[A-Za-z0-9-]+$/); + const threadId = await activeThreadIdFromUrl(page); + expect(threadId).toBeTruthy(); + + // Reload via direct navigation to /embed/ — assert the existing + // assistant message renders without resending the prompt. + await page.goto(`/embed/${threadId}`); + await expect(page.locator('chat-message[data-role="assistant"]')).toContainText(/hi/i, { + timeout: 30_000, + }); +}); + +test('url routing: deep-link with knob param sets the picker', async ({ page }) => { + await openDemo(page, '/embed?model=gpt-5-nano'); + + // The model toolbar trigger surfaces the current model. Confirm the URL + // value won, not the default. + const modelTrigger = page.locator('.demo-shell__field[data-field="model"] .chat-select__trigger'); + await expect(modelTrigger).toContainText('gpt-5-nano'); +}); + +test('url routing: mode switch preserves thread + knob params', async ({ page }) => { + // Bootstrap: thread + non-default knob. + await openDemo(page, '/embed'); + await messageInput(page).fill('say hi briefly'); + await sendButton(page).click(); + await waitForFinalAssistant(page); + const threadId = await activeThreadIdFromUrl(page); + expect(threadId).toBeTruthy(); + + // Set a non-default model via the toolbar. + const modelTrigger = page.locator('.demo-shell__field[data-field="model"] .chat-select__trigger'); + await modelTrigger.click(); + await page.locator('.chat-select__option', { hasText: 'gpt-5-nano' }).first().click(); + await expect(page).toHaveURL(/[?&]model=gpt-5-nano/); + + // Click Popup mode in the segmented control. + await page.locator('.demo-shell__segmented-button', { hasText: 'Popup' }).click(); + + // URL holds both thread + knob param. + await expect(page).toHaveURL(new RegExp(`/popup/${threadId}(\\?|\\?.*&)model=gpt-5-nano`)); +}); + +test('url routing: ephemeral hydration does not write to localStorage', async ({ page }) => { + // Visit with a non-default theme in the URL. + await openDemo(page, '/embed?theme=material-dark'); + + // openDemo clears localStorage before the test starts; assert it's + // still clean (no `theme: 'material-dark'` written by hydration). + const stored = await page.evaluate(() => { + const raw = localStorage.getItem('ngaf-chat-demo:palette'); + return raw ? (JSON.parse(raw) as { theme?: string }).theme : null; + }); + expect(stored).toBeNull(); +}); From e7f8679e2dbc5a377d185889cef309886c9dbc66 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 15:52:53 -0700 Subject: [PATCH 8/8] test(examples-chat): update model-picker mode-switch URL assertion Mode switch with a non-default knob now preserves the param in the URL (via queryParamsHandling: 'preserve' added by the knob round-trip work). The pre-existing assertion `expect(page).toHaveURL(/\/popup$/)` expected the bare path. Updated to match the new behavior: `/popup?...model=gpt-5-nano`. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/chat/angular/e2e/model-picker.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/chat/angular/e2e/model-picker.spec.ts b/examples/chat/angular/e2e/model-picker.spec.ts index ec2e2b5f..fcc6f5b0 100644 --- a/examples/chat/angular/e2e/model-picker.spec.ts +++ b/examples/chat/angular/e2e/model-picker.spec.ts @@ -38,7 +38,9 @@ test('model picker: configured models render, persist, and reach backend state', await page .locator('.demo-shell__segmented-button', { hasText: 'Popup' }) .click(); - await expect(page).toHaveURL(/\/popup$/); + // Mode switch now preserves the non-default model knob in the URL + // via queryParamsHandling: 'preserve' (knob round-trip work). + await expect(page).toHaveURL(/\/popup\?.*model=gpt-5-nano/); await expect(toolbarSelect(page, 'Model')).toHaveText(/gpt-5-nano/); await page.goto('/embed');