@@ -17,12 +17,15 @@ export interface SavedTabChrome {
1717 titleLocked ?: boolean ;
1818}
1919
20- // The persisted shape of a project + its open tabs (engine session id + title).
20+ // The persisted shape of a project + its open tabs. `id` is the desktop
21+ // session id (stable across restore so layout chat tiles keep matching);
22+ // `sid` is the engine conversation to resume, present only when the engine
23+ // actually persisted one.
2124export interface SavedProject {
2225 id : string ;
2326 name : string ;
2427 path : string ;
25- tabs ?: ( { sid : string ; title : string ; backend ?: string ; archived ?: boolean } & SavedTabChrome ) [ ] ;
28+ tabs ?: ( { id ?: string ; sid ? : string ; title : string ; backend ?: string ; archived ?: boolean } & SavedTabChrome ) [ ] ;
2629 /** 并行任务 worktree 项目的元数据(isWorktree/mainRepoPath/branch/baseBranch/slug)。 */
2730 worktree ?: WorktreeMeta ;
2831 /** 本项目最近一次新建会话所用的引擎后端(缺省 = jucode)。 */
@@ -84,9 +87,11 @@ export class SessionStore {
8487
8588 /** Builds a session record (chat + per-session adapter) and registers the
8689 * adapter with the op router. `acpAgent` (acp backend only) records which
87- * registry agent backs the session, for spawn opts and display. */
88- #newSession( backendId : BackendId , acpAgent ?: { id : string ; name : string } ) : Session {
89- const id = this . uid ( ) ;
90+ * registry agent backs the session, for spawn opts and display. `reuseId`
91+ * re-applies a persisted desktop id (layout chat tiles key on it) unless
92+ * this run already spawned it. */
93+ #newSession( backendId : BackendId , acpAgent ?: { id : string ; name : string } , reuseId ?: string ) : Session {
94+ const id = reuseId && ! this . allSessions . some ( ( s ) => s . id === reuseId ) ? reuseId : this . uid ( ) ;
9095 const chat = new ChatState ( ) ;
9196 chat . backendId = backendId ;
9297 if ( acpAgent ) {
@@ -281,9 +286,10 @@ export class SessionStore {
281286 title : string ,
282287 backend : BackendId = 'jucode' ,
283288 archived = false ,
284- chrome ?: SavedTabChrome
289+ chrome ?: SavedTabChrome ,
290+ reuseId ?: string
285291 ) {
286- const s = this . #newSession( backend ) ;
292+ const s = this . #newSession( backend , undefined , reuseId ) ;
287293 if ( title ) s . chat . title = title ;
288294 s . archived = archived ;
289295 if ( chrome ?. color ) s . color = chrome . color ;
@@ -306,6 +312,31 @@ export class SessionStore {
306312 return s . id ;
307313 }
308314
315+ /** Spawn a fresh session for a persisted tab that has no engine
316+ * conversation to resume (never sent a turn), reusing the saved desktop
317+ * id so layout chat tiles keep matching across workspace switches. */
318+ #spawnSaved(
319+ project : Project ,
320+ reuseId : string ,
321+ title : string ,
322+ backend : BackendId = 'jucode' ,
323+ archived = false ,
324+ chrome ?: SavedTabChrome
325+ ) {
326+ const s = this . #newSession( backend , undefined , reuseId ) ;
327+ if ( title ) s . chat . title = title ;
328+ s . archived = archived ;
329+ if ( chrome ?. color ) s . color = chrome . color ;
330+ if ( chrome ?. icon ) s . icon = chrome . icon ;
331+ if ( chrome ?. titleLocked ) s . chat . titleLocked = true ;
332+ project . sessions . push ( s ) ;
333+ // Same rationale as addSession: pin a resumable uuid for claude.
334+ const extra = backend === 'claude' ? { session_id : newUuid ( ) } : undefined ;
335+ if ( extra ) s . chat . sessionId = extra . session_id ;
336+ this . #spawn( s , project . path , undefined , extra ) . catch ( ( e ) => this . #engineFailed( s . chat , e ) ) ;
337+ return s . id ;
338+ }
339+
309340 /** Best-effort transcript replay for a resumed claude session: the session
310341 * file's user/assistant text becomes the message list (caps.transcriptReplay).
311342 * Failures are silent — `--resume` already restored the engine-side context,
@@ -527,9 +558,11 @@ export class SessionStore {
527558 dispatch ( id , { op : 'command' , input : '/resume' } ) ;
528559 }
529560
530- /** Snapshot of the layout + open tabs for persistence. The backend id is
531- * only written when it isn't the default, so pre-existing layouts stay
532- * byte-identical. */
561+ /** Snapshot of the layout + open tabs for persistence. Every session is
562+ * written (empty windows survive a workspace switch under their desktop
563+ * id); `sid` only when the engine actually persisted the conversation —
564+ * never `/resume` one it didn't. The backend id is only written when it
565+ * isn't the default, so pre-existing layouts stay byte-identical. */
533566 serialize ( ) : SavedProject [ ] {
534567 return this . projects . map ( ( p ) => ( {
535568 id : p . id ,
@@ -539,9 +572,9 @@ export class SessionStore {
539572 ...( p . lastBackend && p . lastBackend !== 'jucode' ? { lastBackend : p . lastBackend } : { } ) ,
540573 ...( p . lastBackend === 'acp' && p . lastAcpAgent ? { lastAcpAgent : p . lastAcpAgent } : { } ) ,
541574 tabs : p . sessions
542- . filter ( ( s ) => s . chat . resumable )
543575 . map ( ( s ) => ( {
544- sid : s . chat . sessionId ,
576+ id : s . id ,
577+ ...( s . chat . resumable ? { sid : s . chat . sessionId } : { } ) ,
545578 title : s . chat . title ,
546579 ...( s . backendId !== 'jucode' ? { backend : s . backendId } : { } ) ,
547580 ...( s . archived ? { archived : true } : { } ) ,
@@ -582,15 +615,21 @@ export class SessionStore {
582615 this . projects . push ( proj ) ;
583616 if ( proj . stale ) continue ;
584617 for ( const t of p . tabs ?? [ ] ) {
585- if ( ! t . sid ) continue ;
618+ if ( ! t . sid && ! t . id ) continue ;
586619 // Tabs saved before multi-backend support carry no backend field →
587620 // jucode (normalizeBackendId maps unknown/missing to the default).
588621 // Chrome fields are re-validated here (the file is user-editable).
589- const id = this . restoreSession ( proj , t . sid , t . title , normalizeBackendId ( t . backend ) , ! ! t . archived , {
622+ const backend = normalizeBackendId ( t . backend ) ;
623+ const chrome = {
590624 color : normalizeColor ( t . color ) ,
591625 icon : parseTabIcon ( t . icon ) ,
592626 titleLocked : ! ! t . titleLocked
593- } ) ;
627+ } ;
628+ // With a conversation to resume, resume it; an empty window spawns
629+ // fresh. Both keep the saved desktop id (pre-id files mint anew).
630+ const id = t . sid
631+ ? this . restoreSession ( proj , t . sid , t . title , backend , ! ! t . archived , chrome , t . id )
632+ : this . #spawnSaved( proj , t . id ! , t . title , backend , ! ! t . archived , chrome ) ;
594633 if ( ! first && ! t . archived ) first = id ;
595634 }
596635 }
0 commit comments