@@ -39,14 +39,9 @@ export type DemoMode = 'embed' | 'popup' | 'sidebar';
3939const MODES : readonly DemoMode [ ] = [ 'embed' , 'popup' , 'sidebar' ] as const ;
4040const 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