Skip to content

Commit 2cdf7cf

Browse files
committed
Revert "feat(examples-chat): URL-based thread routing — /<mode>/:threadId (#500)"
This reverts commit 1201712.
1 parent 2fc9270 commit 2cdf7cf

5 files changed

Lines changed: 26 additions & 281 deletions

File tree

docs/superpowers/specs/2026-05-20-url-thread-routing-design.md

Lines changed: 0 additions & 112 deletions
This file was deleted.

examples/chat/angular/src/app/app.routes.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
// SPDX-License-Identifier: MIT
22
import { Routes } from '@angular/router';
33

4-
// Each mode gets two route entries: a stateless `<mode>` and a
5-
// thread-scoped `<mode>/:threadId`. Angular Router doesn't support
6-
// `?`-style optional params, hence the duplication. DemoShell's
7-
// URL ↔ signal sync (see spec 2026-05-20-url-thread-routing-design.md)
8-
// reads `route.firstChild.paramMap.threadId` so both shapes feed the
9-
// same handler.
104
export const routes: Routes = [
115
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
126
{
@@ -19,31 +13,16 @@ export const routes: Routes = [
1913
loadComponent: () =>
2014
import('./modes/embed-mode.component').then((m) => m.EmbedMode),
2115
},
22-
{
23-
path: 'embed/:threadId',
24-
loadComponent: () =>
25-
import('./modes/embed-mode.component').then((m) => m.EmbedMode),
26-
},
2716
{
2817
path: 'popup',
2918
loadComponent: () =>
3019
import('./modes/popup-mode.component').then((m) => m.PopupMode),
3120
},
32-
{
33-
path: 'popup/:threadId',
34-
loadComponent: () =>
35-
import('./modes/popup-mode.component').then((m) => m.PopupMode),
36-
},
3721
{
3822
path: 'sidebar',
3923
loadComponent: () =>
4024
import('./modes/sidebar-mode.component').then((m) => m.SidebarMode),
4125
},
42-
{
43-
path: 'sidebar/:threadId',
44-
loadComponent: () =>
45-
import('./modes/sidebar-mode.component').then((m) => m.SidebarMode),
46-
},
4726
],
4827
},
4928
{ path: '**', redirectTo: 'embed' },

examples/chat/angular/src/app/shell/demo-shell.component.ts

Lines changed: 24 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,9 @@ export type DemoMode = 'embed' | 'popup' | 'sidebar';
3939
const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const;
4040
const TELEMETRY_SURFACE = 'canonical_demo';
4141

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

5247
@Component({
@@ -114,46 +109,6 @@ export class DemoShell {
114109
void this.threadsSvc.refresh();
115110
});
116111

117-
// URL → signal. When the URL's threadId changes (paste link, back/
118-
// forward, programmatic navigation), reflect it into threadIdSignal.
119-
// The compare-and-set guard breaks the obvious URL→signal→URL loop:
120-
// by the time the signal→URL effect below fires, both values match
121-
// and `router.navigate` is skipped.
122-
// URL → signal sync.
123-
effect(() => {
124-
const urlId = this.urlThreadId();
125-
if (urlId !== this.threadIdSignal()) {
126-
this.threadIdSignal.set(urlId);
127-
}
128-
});
129-
130-
// Validate URL thread ids whenever they appear. Decoupled from the
131-
// sync effect above: on initial load the signal is hydrated from
132-
// the URL synchronously (field initializer), so the sync guard
133-
// would skip validation. This effect runs once per distinct id,
134-
// including the initial one. Cache last-validated to avoid
135-
// re-hitting the server on signal flips that round-trip the same
136-
// id back through.
137-
let lastValidated: string | null = null;
138-
effect(() => {
139-
const urlId = this.urlThreadId();
140-
if (urlId && urlId !== lastValidated) {
141-
lastValidated = urlId;
142-
void this.validateUrlThreadId(urlId);
143-
}
144-
});
145-
146-
// signal → URL. When the agent auto-creates a thread, the sidenav
147-
// switches threads, or onNewThread fires, push the new id into the
148-
// URL. Skips when the URL already matches (also breaks the loop).
149-
effect(() => {
150-
const sigId = this.threadIdSignal();
151-
const { mode, threadId: urlId } = this.urlState();
152-
if (sigId === urlId) return;
153-
const cmds: unknown[] = sigId ? ['/', mode, sigId] : ['/', mode];
154-
void this.router.navigate(cmds as string[]);
155-
});
156-
157112
// Refresh threads list when an agent run completes. The backend writes
158113
// metadata.title on the first user message via _maybe_write_thread_title;
159114
// a refresh after run-end picks up the new title in the drawer without
@@ -175,22 +130,16 @@ export class DemoShell {
175130
});
176131
}
177132

178-
/** Parsed URL — single source for both the active mode AND the URL's
179-
* thread id. Refreshes on every NavigationEnd so back/forward and
180-
* programmatic navigations both feed downstream effects. */
181-
private readonly urlState = toSignal(
133+
protected readonly mode = toSignal(
182134
this.router.events.pipe(
183135
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
184-
map((e) => parseUrl(e.urlAfterRedirects)),
185-
startWith(parseUrl(this.router.url)),
136+
map((e) => modeFromUrl(e.urlAfterRedirects)),
137+
startWith(modeFromUrl(this.router.url)),
186138
takeUntilDestroyed(),
187139
),
188-
{ initialValue: parseUrl(this.router.url) },
140+
{ initialValue: modeFromUrl(this.router.url) },
189141
);
190142

191-
protected readonly mode = computed<DemoMode>(() => this.urlState().mode);
192-
private readonly urlThreadId = computed<string | null>(() => this.urlState().threadId);
193-
194143
/**
195144
* Source of truth for the model picker. The shell owns it; the
196145
* patched submit injects it into state on every send.
@@ -307,11 +256,8 @@ export class DemoShell {
307256
{ value: 'material-light', label: 'Material light' },
308257
]);
309258

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

316262
/** Title of the currently-selected thread, or 'New chat' if none. The
317263
* Python graph writes thread.metadata.title from the first user message
@@ -347,12 +293,18 @@ export class DemoShell {
347293
protected readonly threadActions: ThreadActionAdapter = {
348294
delete: async (id) => {
349295
await this.threadsSvc.delete(id);
350-
if (this.threadIdSignal() === id) this.threadIdSignal.set(null);
296+
if (this.threadIdSignal() === id) {
297+
this.threadIdSignal.set(null);
298+
this.persistence.write('threadId', null);
299+
}
351300
},
352301
rename: (id, title) => this.threadsSvc.rename(id, title),
353302
archive: async (id) => {
354303
await this.threadsSvc.archive(id);
355-
if (this.threadIdSignal() === id) this.threadIdSignal.set(null);
304+
if (this.threadIdSignal() === id) {
305+
this.threadIdSignal.set(null);
306+
this.persistence.write('threadId', null);
307+
}
356308
},
357309
unarchive: (id) => this.threadsSvc.unarchive(id),
358310
pin: (id) => this.threadsSvc.pin(id),
@@ -374,10 +326,8 @@ export class DemoShell {
374326
assistantId: environment.assistantId,
375327
threadId: this.threadIdSignal,
376328
onThreadId: (id: string) => {
377-
// The signal→URL effect picks this up and stamps the new id
378-
// into the URL — no persistence write needed any more, URL is
379-
// the source of truth.
380329
this.threadIdSignal.set(id);
330+
this.persistence.write('threadId', id);
381331
},
382332
// Phase 3B: tells SubagentTracker to treat `research` tool calls as
383333
// subagent dispatches and to materialize agent.subagents() from the
@@ -411,21 +361,7 @@ export class DemoShell {
411361
})();
412362

413363
protected onModeChange(next: DemoMode | string): void {
414-
// Preserve the active thread across mode switches: /embed/abc →
415-
// /popup/abc keeps the conversation visible in the new chrome.
416-
const id = this.threadIdSignal();
417-
void this.router.navigate(id ? ['/', next, id] : ['/', next]);
418-
}
419-
420-
/** Silently redirect to the bare mode path when the URL's threadId
421-
* resolves to a 404. Uses `replaceUrl: true` so the back button
422-
* doesn't reload the broken link. Non-404 errors propagate from
423-
* the adapter as-is (genuine transport failures shouldn't be
424-
* swallowed). */
425-
private async validateUrlThreadId(threadId: string): Promise<void> {
426-
const thread = await this.threadsSvc.getThread(threadId);
427-
if (thread) return;
428-
await this.router.navigate(['/', this.mode()], { replaceUrl: true });
364+
void this.router.navigate(['/' + next]);
429365
}
430366

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

477414
protected onProjectSelected(projectId: string): void {
@@ -494,7 +431,10 @@ export class DemoShell {
494431
protected async onNewThread(): Promise<void> {
495432
const sel = this.selectedProjectId();
496433
const id = await this.threadsSvc.create(sel ? { projectId: sel } : {});
497-
if (id) this.threadIdSignal.set(id);
434+
if (id) {
435+
this.threadIdSignal.set(id);
436+
this.persistence.write('threadId', id);
437+
}
498438
}
499439

500440
/**

0 commit comments

Comments
 (0)