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
112 changes: 112 additions & 0 deletions docs/superpowers/specs/2026-05-20-url-thread-routing-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# URL-based thread routing — design

## Goal

Make the active LangGraph thread part of the URL so links to specific
conversations on the canonical demo are shareable and survive reload.

## Current state

`DemoShell.threadIdSignal = signal<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).

## URL shape

```
/<mode>/: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<Thread | null>
```

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 `<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

## 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
21 changes: 21 additions & 0 deletions examples/chat/angular/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// SPDX-License-Identifier: MIT
import { Routes } from '@angular/router';

// Each mode gets two route entries: a stateless `<mode>` and a
// thread-scoped `<mode>/:threadId`. Angular Router doesn't support
// `?`-style optional params, hence the duplication. DemoShell's
// URL ↔ signal sync (see spec 2026-05-20-url-thread-routing-design.md)
// reads `route.firstChild.paramMap.threadId` so both shapes feed the
// same handler.
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
{
Expand All @@ -13,16 +19,31 @@ export const routes: Routes = [
loadComponent: () =>
import('./modes/embed-mode.component').then((m) => m.EmbedMode),
},
{
path: 'embed/:threadId',
loadComponent: () =>
import('./modes/embed-mode.component').then((m) => m.EmbedMode),
},
{
path: 'popup',
loadComponent: () =>
import('./modes/popup-mode.component').then((m) => m.PopupMode),
},
{
path: 'popup/:threadId',
loadComponent: () =>
import('./modes/popup-mode.component').then((m) => m.PopupMode),
},
{
path: 'sidebar',
loadComponent: () =>
import('./modes/sidebar-mode.component').then((m) => m.SidebarMode),
},
{
path: 'sidebar/:threadId',
loadComponent: () =>
import('./modes/sidebar-mode.component').then((m) => m.SidebarMode),
},
],
},
{ path: '**', redirectTo: 'embed' },
Expand Down
108 changes: 84 additions & 24 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,14 @@ export type DemoMode = 'embed' | 'popup' | 'sidebar';
const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const;
const TELEMETRY_SURFACE = 'canonical_demo';

function modeFromUrl(url: string): DemoMode {
const seg = url.split('?')[0].split('/').filter(Boolean)[0];
return (MODES as readonly string[]).includes(seg) ? (seg as DemoMode) : 'embed';
/** Parse `/embed`, `/embed/<threadId>`, `/popup/<threadId>` etc. into
* `{mode, threadId}`. Source of truth for URL ↔ signal sync — see
* spec 2026-05-20-url-thread-routing-design.md. */
function parseUrl(url: string): { mode: DemoMode; threadId: string | null } {
const segs = url.split('?')[0].split('#')[0].split('/').filter(Boolean);
const mode = (MODES as readonly string[]).includes(segs[0]) ? (segs[0] as DemoMode) : 'embed';
const threadId = segs[1] && segs[1].length > 0 ? segs[1] : null;
return { mode, threadId };
}

@Component({
Expand Down Expand Up @@ -109,6 +114,46 @@ export class DemoShell {
void this.threadsSvc.refresh();
});

// URL → signal. When the URL's threadId changes (paste link, back/
// forward, programmatic navigation), reflect it into threadIdSignal.
// The compare-and-set guard breaks the obvious URL→signal→URL loop:
// by the time the signal→URL effect below fires, both values match
// and `router.navigate` is skipped.
// URL → signal sync.
effect(() => {
const urlId = this.urlThreadId();
if (urlId !== this.threadIdSignal()) {
this.threadIdSignal.set(urlId);
}
});

// Validate URL thread ids whenever they appear. Decoupled from the
// sync effect above: on initial load the signal is hydrated from
// the URL synchronously (field initializer), so the sync guard
// would skip validation. This effect runs once per distinct id,
// including the initial one. Cache last-validated to avoid
// re-hitting the server on signal flips that round-trip the same
// id back through.
let lastValidated: string | null = null;
effect(() => {
const urlId = this.urlThreadId();
if (urlId && urlId !== lastValidated) {
lastValidated = urlId;
void this.validateUrlThreadId(urlId);
}
});

// signal → URL. When the agent auto-creates a thread, the sidenav
// switches threads, or onNewThread fires, push the new id into the
// URL. Skips when the URL already matches (also breaks the loop).
effect(() => {
const sigId = this.threadIdSignal();
const { mode, threadId: urlId } = this.urlState();
if (sigId === urlId) return;
const cmds: unknown[] = sigId ? ['/', mode, sigId] : ['/', mode];
void this.router.navigate(cmds as string[]);
});

// Refresh threads list when an agent run completes. The backend writes
// metadata.title on the first user message via _maybe_write_thread_title;
// a refresh after run-end picks up the new title in the drawer without
Expand All @@ -130,16 +175,22 @@ export class DemoShell {
});
}

protected readonly mode = toSignal(
/** Parsed URL — single source for both the active mode AND the URL's
* thread id. Refreshes on every NavigationEnd so back/forward and
* programmatic navigations both feed downstream effects. */
private readonly urlState = toSignal(
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
map((e) => modeFromUrl(e.urlAfterRedirects)),
startWith(modeFromUrl(this.router.url)),
map((e) => parseUrl(e.urlAfterRedirects)),
startWith(parseUrl(this.router.url)),
takeUntilDestroyed(),
),
{ initialValue: modeFromUrl(this.router.url) },
{ initialValue: parseUrl(this.router.url) },
);

protected readonly mode = computed<DemoMode>(() => this.urlState().mode);
private readonly urlThreadId = computed<string | null>(() => this.urlState().threadId);

/**
* Source of truth for the model picker. The shell owns it; the
* patched submit injects it into state on every send.
Expand Down Expand Up @@ -256,8 +307,11 @@ export class DemoShell {
{ value: 'material-light', label: 'Material light' },
]);

/** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */
protected readonly threadIdSignal = signal<string | null>(this.persistence.read('threadId') ?? null);
/** Active thread id. URL is the source of truth (see urlState above);
* this signal initialises from the URL on construction and is kept in
* sync by the bidirectional effects in the constructor. The agent
* watches this signal directly. */
protected readonly threadIdSignal = signal<string | null>(parseUrl(this.router.url).threadId);

/** Title of the currently-selected thread, or 'New chat' if none. The
* Python graph writes thread.metadata.title from the first user message
Expand Down Expand Up @@ -293,18 +347,12 @@ export class DemoShell {
protected readonly threadActions: ThreadActionAdapter = {
delete: async (id) => {
await this.threadsSvc.delete(id);
if (this.threadIdSignal() === id) {
this.threadIdSignal.set(null);
this.persistence.write('threadId', null);
}
if (this.threadIdSignal() === id) this.threadIdSignal.set(null);
},
rename: (id, title) => this.threadsSvc.rename(id, title),
archive: async (id) => {
await this.threadsSvc.archive(id);
if (this.threadIdSignal() === id) {
this.threadIdSignal.set(null);
this.persistence.write('threadId', null);
}
if (this.threadIdSignal() === id) this.threadIdSignal.set(null);
},
unarchive: (id) => this.threadsSvc.unarchive(id),
pin: (id) => this.threadsSvc.pin(id),
Expand All @@ -326,8 +374,10 @@ export class DemoShell {
assistantId: environment.assistantId,
threadId: this.threadIdSignal,
onThreadId: (id: string) => {
// The signal→URL effect picks this up and stamps the new id
// into the URL — no persistence write needed any more, URL is
// the source of truth.
this.threadIdSignal.set(id);
this.persistence.write('threadId', id);
},
// Phase 3B: tells SubagentTracker to treat `research` tool calls as
// subagent dispatches and to materialize agent.subagents() from the
Expand Down Expand Up @@ -361,7 +411,21 @@ export class DemoShell {
})();

protected onModeChange(next: DemoMode | string): void {
void this.router.navigate(['/' + next]);
// Preserve the active thread across mode switches: /embed/abc →
// /popup/abc keeps the conversation visible in the new chrome.
const id = this.threadIdSignal();
void this.router.navigate(id ? ['/', next, id] : ['/', next]);
}

/** Silently redirect to the bare mode path when the URL's threadId
* resolves to a 404. Uses `replaceUrl: true` so the back button
* doesn't reload the broken link. Non-404 errors propagate from
* the adapter as-is (genuine transport failures shouldn't be
* swallowed). */
private async validateUrlThreadId(threadId: string): Promise<void> {
const thread = await this.threadsSvc.getThread(threadId);
if (thread) return;
await this.router.navigate(['/', this.mode()], { replaceUrl: true });
}

onModelChange(next: string): void {
Expand Down Expand Up @@ -408,7 +472,6 @@ export class DemoShell {
/** Switch to an existing thread selected from the threads panel. */
protected onThreadSelected(threadId: string): void {
this.threadIdSignal.set(threadId);
this.persistence.write('threadId', threadId);
}

protected onProjectSelected(projectId: string): void {
Expand All @@ -431,10 +494,7 @@ export class DemoShell {
protected async onNewThread(): Promise<void> {
const sel = this.selectedProjectId();
const id = await this.threadsSvc.create(sel ? { projectId: sel } : {});
if (id) {
this.threadIdSignal.set(id);
this.persistence.write('threadId', id);
}
if (id) this.threadIdSignal.set(id);
}

/**
Expand Down
Loading
Loading