@@ -88,9 +88,13 @@ export class StreamingMessageAggregator {
8888 private agentStatus : { emoji : string ; message : string ; url ?: string } | undefined = undefined ;
8989
9090 // Last URL set via status_set - persists even when agentStatus is cleared
91- // This ensures URL stays available across stream boundaries
91+ // This ensures URL stays available across stream boundaries and through compaction
92+ // Persisted to localStorage keyed by workspaceId
9293 private lastStatusUrl : string | undefined = undefined ;
9394
95+ // Workspace ID for localStorage persistence
96+ private readonly workspaceId : string | undefined ;
97+
9498 // Workspace init hook state (ephemeral, not persisted to history)
9599 private initState : {
96100 status : "running" | "success" | "error" ;
@@ -111,10 +115,47 @@ export class StreamingMessageAggregator {
111115 // REQUIRED: Backend guarantees every workspace has createdAt via config.ts
112116 private readonly createdAt : string ;
113117
114- constructor ( createdAt : string ) {
118+ constructor ( createdAt : string , workspaceId ?: string ) {
115119 this . createdAt = createdAt ;
120+ this . workspaceId = workspaceId ;
121+ // Load persisted lastStatusUrl from localStorage
122+ if ( workspaceId ) {
123+ this . lastStatusUrl = this . loadLastStatusUrl ( ) ;
124+ }
116125 this . updateRecency ( ) ;
117126 }
127+
128+ /** localStorage key for persisting lastStatusUrl. Only call when workspaceId is defined. */
129+ private getStatusUrlKey ( ) : string | undefined {
130+ if ( ! this . workspaceId ) return undefined ;
131+ return `mux:workspace:${ this . workspaceId } :lastStatusUrl` ;
132+ }
133+
134+ /** Load lastStatusUrl from localStorage */
135+ private loadLastStatusUrl ( ) : string | undefined {
136+ const key = this . getStatusUrlKey ( ) ;
137+ if ( ! key ) return undefined ;
138+ try {
139+ const stored = localStorage . getItem ( key ) ;
140+ return stored ?? undefined ;
141+ } catch {
142+ return undefined ;
143+ }
144+ }
145+
146+ /**
147+ * Persist lastStatusUrl to localStorage.
148+ * Once set, the URL can only be replaced with a new URL, never deleted.
149+ */
150+ private saveLastStatusUrl ( url : string ) : void {
151+ const key = this . getStatusUrlKey ( ) ;
152+ if ( ! key ) return ;
153+ try {
154+ localStorage . setItem ( key , url ) ;
155+ } catch {
156+ // Ignore localStorage errors
157+ }
158+ }
118159 private invalidateCache ( ) : void {
119160 this . cachedAllMessages = null ;
120161 this . cachedDisplayedMessages = null ;
@@ -232,16 +273,39 @@ export class StreamingMessageAggregator {
232273 this . messages . set ( message . id , message ) ;
233274 }
234275
235- // Then, reconstruct derived state from the most recent assistant message
236276 // Use "streaming" context if there's an active stream (reconnection), otherwise "historical"
237277 const context = hasActiveStream ? "streaming" : "historical" ;
238278
239- const sortedMessages = [ ...messages ] . sort (
240- ( a , b ) => ( b . metadata ?. historySequence ?? 0 ) - ( a . metadata ?. historySequence ?? 0 )
279+ // Sort messages in chronological order for processing
280+ const chronologicalMessages = [ ...messages ] . sort (
281+ ( a , b ) => ( a . metadata ?. historySequence ?? 0 ) - ( b . metadata ?. historySequence ?? 0 )
241282 ) ;
242283
243- // Find the most recent assistant message
244- const lastAssistantMessage = sortedMessages . find ( ( msg ) => msg . role === "assistant" ) ;
284+ // First pass: scan all messages to build up lastStatusUrl from tool calls
285+ // This ensures URL persistence works even if the URL was set in an earlier message
286+ // Also persists to localStorage for future loads (survives compaction)
287+ for ( const message of chronologicalMessages ) {
288+ if ( message . role === "assistant" ) {
289+ for ( const part of message . parts ) {
290+ if (
291+ isDynamicToolPart ( part ) &&
292+ part . state === "output-available" &&
293+ part . toolName === "status_set" &&
294+ hasSuccessResult ( part . output )
295+ ) {
296+ const result = part . output as Extract < StatusSetToolResult , { success : true } > ;
297+ if ( result . url ) {
298+ this . lastStatusUrl = result . url ;
299+ this . saveLastStatusUrl ( result . url ) ;
300+ }
301+ }
302+ }
303+ }
304+ }
305+
306+ // Second pass: reconstruct derived state from the most recent assistant message only
307+ // (TODOs and agentStatus should reflect only the latest state)
308+ const lastAssistantMessage = chronologicalMessages . findLast ( ( msg ) => msg . role === "assistant" ) ;
245309
246310 if ( lastAssistantMessage ) {
247311 // Process all tool results from the most recent assistant message
@@ -577,9 +641,10 @@ export class StreamingMessageAggregator {
577641 if ( toolName === "status_set" && hasSuccessResult ( output ) ) {
578642 const result = output as Extract < StatusSetToolResult , { success : true } > ;
579643
580- // Update lastStatusUrl if a new URL is provided
644+ // Update lastStatusUrl if a new URL is provided, and persist to localStorage
581645 if ( result . url ) {
582646 this . lastStatusUrl = result . url ;
647+ this . saveLastStatusUrl ( result . url ) ;
583648 }
584649
585650 // Use the provided URL, or fall back to the last URL ever set
0 commit comments