Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 102 additions & 41 deletions docs/superpowers/specs/2026-05-20-url-thread-routing-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,105 @@
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
## URL is the source of truth

`DemoShell.threadIdSignal = signal<string | null>(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).
The URL is the **sole** source of truth for the active thread. The
shell does not persist the active thread to localStorage:

## URL shape
- `/embed`, `/popup`, `/sidebar` — bare mode paths mean "no active
thread" (welcome state).
- `/embed/<id>`, `/popup/<id>`, `/sidebar/<id>` — that thread, in that
presentation mode.

```
/<mode>/:threadId?
```
Sharing `/embed/abc` lands on thread `abc`. Sharing `/embed` always
lands on the welcome state, regardless of what the recipient's browser
last used. There is no localStorage fallback to "last active thread"
— that conflates user intent with browser-local state and breaks
shareability.

`:threadId` is optional. Angular doesn't support `?` syntax for
optional params, so each mode gets two route entries:
## Route shape

Each mode gets a single route entry via `UrlMatcher` that accepts both
`<mode>` and `<mode>/<threadId>` shapes under one entry. This is
critical: a per-shape pair (two entries) causes Angular to destroy and
remount the mode component when navigating `/embed` → `/embed/<id>`,
which kills any in-flight agent stream (see PR #500/#504 history).

```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 },
function modeMatcher(modeName: string): UrlMatcher {
return (segments) => {
if (segments.length === 0 || segments[0].path !== modeName) return null;
if (segments.length === 1) return { consumed: segments, posParams: {} };
if (segments.length === 2) {
return { consumed: segments, posParams: { threadId: segments[1] } };
}
return null;
};
}

export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
{
path: '',
loadComponent: () => import('./shell/demo-shell.component').then((m) => m.DemoShell),
children: [
{ matcher: modeMatcher('embed'), loadComponent: () => import('./modes/embed-mode.component').then((m) => m.EmbedMode) },
{ matcher: modeMatcher('popup'), loadComponent: () => import('./modes/popup-mode.component').then((m) => m.PopupMode) },
{ matcher: modeMatcher('sidebar'), loadComponent: () => import('./modes/sidebar-mode.component').then((m) => m.SidebarMode) },
],
},
{ path: '**', redirectTo: 'embed' },
];
```

## URL ↔ signal sync (in DemoShell)
DemoShell parses the URL itself (via `router.url` + a NavigationEnd
`toSignal`) rather than reading param maps from `route.firstChild`,
because the param data lives on the matched route under `posParams`
and is more reliably read this way.

URL is the source of truth when present; localStorage falls back when
the URL has no id.
## URL ↔ signal sync (in DemoShell)

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.
1. **URL → signal.** A `toSignal(NavigationEnd)` pipes the current URL
through `parseUrl()` to extract `{mode, threadId}`. An `effect`
reads the URL's `threadId` and writes it into `threadIdSignal` iff
it differs from the current value. The signal read is `untracked`
so the effect only fires on URL changes, not on imperative signal
writes (critical for the stamp-in-progress async gap — see below).

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.
doesn't already match. Uses default `replaceUrl: false` so the
back button walks through visited threads.

The compare-and-set guard in flow 1 prevents the obvious
URL→signal→URL loop: by the time the signal→URL effect fires, the
values match and `router.navigate` is skipped.

### Stamp-in-progress invariant

When the agent auto-creates a thread mid-send, the `onThreadId`
callback fires immediately and sets `threadIdSignal`. The signal→URL
effect then navigates asynchronously. During the gap, the URL is
still bare. The URL→signal effect MUST NOT clear the just-set signal
back to `null` during this window — that would lose the agent's
allocation. This is enforced by:

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.
- The URL→signal effect tracks only `urlThreadId()`, not the signal.
Imperative signal writes don't refire it.
- The signal read inside the effect is `untracked`.

There is no test covering the no-nav-loop invariant directly; the
"does not clear an agent-created thread id while URL navigation is
still pending" test (`demo-shell.component.spec.ts`) covers the
stamp-in-progress case.

## 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:
deleted thread, link from another browser), silently redirect to the
bare mode path:

```ts
const thread = await threadsSvc.getThread(id);
Expand All @@ -67,20 +112,25 @@ 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`:
Validation runs as a separate `effect` from the URL→signal sync, with
a `lastValidated` closure variable to dedupe — `getThread` is async
and we don't want to re-hit the server on every signal flip that
round-trips the same id.

Requires `LangGraphThreadsAdapter.getThread()`:

```ts
async getThread(threadId: string): Promise<Thread | null>
```

Wraps `client.threads.get(id)`. Returns `null` on 404 (caught from
the SDK's thrown error); rethrows on other failures so genuine
Wraps `client.threads.get(id)`. Returns `null` on 404 and 422 (the
latter for malformed UUIDs); 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:
handler navigates with the current id:

```ts
protected onModeChange(next: DemoMode | string): void {
Expand All @@ -89,22 +139,33 @@ protected onModeChange(next: DemoMode | string): void {
}
```

This is the **only** mechanism that preserves the active thread
across mode boundaries. There is no localStorage backstop — direct
URL navigation to `/popup` (e.g. paste link, back button) clears the
active thread.

## Out of scope

- Server-side render of `<title>`/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
- Round-tripping agent knobs (model, effort, theme, ...) via query
params — see follow-up #494

## Test plan

- `LangGraphThreadsAdapter.getThread()` — returns `Thread` for an
existing id, returns `null` for a missing id, rethrows on other
errors
existing id, returns `null` for a missing id (404 or 422), 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
- Bare-mode route loads → `threadIdSignal()` is `null` regardless of
any legacy localStorage state
- Agent-allocated thread id survives the URL navigation async gap
(stamp-in-progress invariant)
- 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
Expand Down
22 changes: 10 additions & 12 deletions examples/chat/angular/e2e/lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
import { test, expect } from '@playwright/test';
import {
activeThreadIdFromUrl,
messageInput,
openDemo,
sendButton,
Expand Down Expand Up @@ -32,28 +33,25 @@ test('lifecycle: New chat (sidenav) starts a fresh thread and restores welcome s
await sendButton(page).click();
await waitForFinalAssistant(page);

const threadIdBefore = await page.evaluate(() => {
const raw = localStorage.getItem('ngaf-chat-demo:palette');
return raw ? (JSON.parse(raw) as { threadId?: string | null }).threadId ?? null : null;
});
// After the first send the agent allocates a thread id and stamps
// it into the URL via the signal→URL effect: /embed/<thread-id>.
await expect(page).toHaveURL(/\/embed\/[A-Za-z0-9-]+$/);
const threadIdBefore = await activeThreadIdFromUrl(page);

// The toolbar "New conversation" button was removed; the sidenav's
// "New chat" pill is now the only affordance for starting a fresh
// thread. It creates a new thread server-side (rather than clearing
// local state) and routes the UI back to the welcome surface.
// thread. It creates a new thread server-side and navigates to
// /embed/<new-thread-id>; the empty thread renders the welcome state.
await page.getByRole('button', { name: 'New chat' }).first().click();

await expect(
page.getByRole('heading', { name: 'How can I help?' })
).toBeVisible();
await expect(page.locator('chat-message')).toHaveCount(0);

const threadIdAfter = await page.evaluate(() => {
const raw = localStorage.getItem('ngaf-chat-demo:palette');
return raw ? (JSON.parse(raw) as { threadId?: string | null }).threadId ?? null : null;
});
// A fresh thread id was persisted, and it's different from the one we
// had before clicking New chat.
// URL holds a fresh thread id, different from the one we had before.
await expect(page).toHaveURL(/\/embed\/[A-Za-z0-9-]+$/);
const threadIdAfter = await activeThreadIdFromUrl(page);
expect(threadIdAfter).toBeTruthy();
expect(threadIdAfter).not.toBe(threadIdBefore);
});
Expand Down
16 changes: 13 additions & 3 deletions examples/chat/angular/e2e/mode-routing.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
import { test, expect } from '@playwright/test';
import {
activeThreadIdFromUrl,
closeChatDevtools,
messageInput,
openDemo,
Expand Down Expand Up @@ -44,22 +45,31 @@ test('cross-mode persistence: conversation follows embed, popup, and sidebar', a
await sendButton(page).click();
await waitForFinalAssistant(page);

await page.goto('/popup');
// Capture the active thread id from the URL — post-URL-as-truth the
// path is /embed/<thread-id>. Cross-mode persistence works by
// navigating to /<other-mode>/<thread-id> directly (bare-mode URLs
// intentionally do NOT restore the last thread — that would conflate
// user intent with browser-local state).
await expect(page).toHaveURL(/\/embed\/[A-Za-z0-9-]+$/);
const threadId = await activeThreadIdFromUrl(page);
expect(threadId).toBeTruthy();

await page.goto(`/popup/${threadId}`);
await closeChatDevtools(page);
await page.locator('.chat-popup__launcher button.chat-launcher-button').click();
await expect(
page.getByRole('dialog').locator('chat-message[data-role="assistant"]'),
).toContainText(/hi/i, { timeout: 30_000 });

await page.goto('/sidebar');
await page.goto(`/sidebar/${threadId}`);
await closeChatDevtools(page);
// Sidebar mode auto-opens the panel; assert the existing conversation
// is visible without a launcher click.
await expect(
page.getByRole('complementary').locator('chat-message[data-role="assistant"]'),
).toContainText(/hi/i, { timeout: 30_000 });

await page.goto('/embed');
await page.goto(`/embed/${threadId}`);
await expect(page.locator('embed-mode chat-message[data-role="assistant"]')).toContainText(/hi/i, {
timeout: 30_000,
});
Expand Down
8 changes: 2 additions & 6 deletions examples/chat/angular/e2e/model-picker.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
import { test, expect } from '@playwright/test';
import {
activeThreadIdFromUrl,
messageInput,
openDemo,
sendButton,
Expand Down Expand Up @@ -45,12 +46,7 @@ test('model picker: configured models render, persist, and reach backend state',
await sendButton(page).click();
await waitForFinalAssistant(page);

const threadId = await page.evaluate(() => {
const raw = localStorage.getItem('ngaf-chat-demo:palette');
return raw
? (JSON.parse(raw) as { threadId?: string }).threadId
: undefined;
});
const threadId = await activeThreadIdFromUrl(page);
expect(threadId).toBeTruthy();
const state = await fetch(
`http://localhost:2024/threads/${threadId}/state`
Expand Down
7 changes: 2 additions & 5 deletions examples/chat/angular/e2e/regenerate.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT
import { test, expect } from '@playwright/test';
import { sendPromptAndWait } from './test-helpers';
import { activeThreadIdFromUrl, sendPromptAndWait } from './test-helpers';

test('regenerate: re-running keeps 1 user / 1 assistant in the conversation', async ({
page,
Expand Down Expand Up @@ -36,10 +36,7 @@ test('regenerate: re-running keeps 1 user / 1 assistant in the conversation', as
await expect(userMessages).toHaveCount(1);
await expect(assistantMessages).toHaveCount(1);

const threadId = await page.evaluate(() => {
const raw = localStorage.getItem('ngaf-chat-demo:palette');
return raw ? (JSON.parse(raw) as { threadId?: string }).threadId : undefined;
});
const threadId = await activeThreadIdFromUrl(page);
expect(threadId).toBeTruthy();
const state = await fetch(`http://localhost:2024/threads/${threadId}/state`).then((r) =>
r.json() as Promise<{ values?: { messages?: unknown[] }; next?: unknown[] }>,
Expand Down
14 changes: 14 additions & 0 deletions examples/chat/angular/e2e/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ export function sendButton(page: Page): Locator {
return page.getByRole('button', { name: 'Send message' });
}

/**
* Read the active thread id from the URL. URL is the source of truth
* for the active thread post-URL-as-truth migration; bare-mode paths
* (`/embed`, `/popup`, `/sidebar`) return `null`.
*
* Use this in place of `JSON.parse(localStorage.getItem(...)).threadId`,
* which no longer exists.
*/
export async function activeThreadIdFromUrl(page: Page): Promise<string | null> {
const url = new URL(page.url());
const segments = url.pathname.split('/').filter(Boolean);
return segments.length >= 2 ? segments[1] : null;
}

/**
* Locate the chat-select trigger inside a toolbar field by its label.
*
Expand Down
Loading