@@ -41,6 +41,24 @@ function getOnChatCallback<T = { type: string }>(): (data: T) => void {
4141 return mock . mock . calls [ 0 ] [ 1 ] ;
4242}
4343
44+ // Helper to create and add a workspace
45+ function createAndAddWorkspace (
46+ store : WorkspaceStore ,
47+ workspaceId : string ,
48+ options : Partial < FrontendWorkspaceMetadata > = { }
49+ ) : FrontendWorkspaceMetadata {
50+ const metadata : FrontendWorkspaceMetadata = {
51+ id : workspaceId ,
52+ name : options . name ?? `test-branch-${ workspaceId } ` ,
53+ projectName : options . projectName ?? "test-project" ,
54+ projectPath : options . projectPath ?? "/path/to/project" ,
55+ namedWorkspacePath : options . namedWorkspacePath ?? "/path/to/worktree" ,
56+ createdAt : options . createdAt ?? new Date ( ) . toISOString ( ) ,
57+ } ;
58+ store . addWorkspace ( metadata ) ;
59+ return metadata ;
60+ }
61+
4462describe ( "WorkspaceStore" , ( ) => {
4563 let store : WorkspaceStore ;
4664 let mockOnModelUsed : jest . Mock ;
@@ -56,6 +74,74 @@ describe("WorkspaceStore", () => {
5674 store . dispose ( ) ;
5775 } ) ;
5876
77+ describe ( "recency calculation for new workspaces" , ( ) => {
78+ it ( "should calculate recency from createdAt when workspace is added" , ( ) => {
79+ const workspaceId = "test-workspace" ;
80+ const createdAt = new Date ( ) . toISOString ( ) ;
81+ const metadata : FrontendWorkspaceMetadata = {
82+ id : workspaceId ,
83+ name : "test-branch" ,
84+ projectName : "test-project" ,
85+ projectPath : "/path/to/project" ,
86+ namedWorkspacePath : "/path/to/worktree" ,
87+ createdAt,
88+ } ;
89+
90+ // Add workspace with createdAt
91+ store . addWorkspace ( metadata ) ;
92+
93+ // Get state - should have recency based on createdAt
94+ const state = store . getWorkspaceState ( workspaceId ) ;
95+
96+ // Recency should be based on createdAt, not null or 0
97+ expect ( state . recencyTimestamp ) . not . toBeNull ( ) ;
98+ expect ( state . recencyTimestamp ) . toBe ( new Date ( createdAt ) . getTime ( ) ) ;
99+
100+ // Check that workspace appears in recency map with correct timestamp
101+ const recency = store . getWorkspaceRecency ( ) ;
102+ expect ( recency [ workspaceId ] ) . toBe ( new Date ( createdAt ) . getTime ( ) ) ;
103+ } ) ;
104+
105+ it ( "should maintain createdAt-based recency after CAUGHT_UP with no messages" , async ( ) => {
106+ const workspaceId = "test-workspace-2" ;
107+ const createdAt = new Date ( ) . toISOString ( ) ;
108+ const metadata : FrontendWorkspaceMetadata = {
109+ id : workspaceId ,
110+ name : "test-branch-2" ,
111+ projectName : "test-project" ,
112+ projectPath : "/path/to/project" ,
113+ namedWorkspacePath : "/path/to/worktree" ,
114+ createdAt,
115+ } ;
116+
117+ // Add workspace
118+ store . addWorkspace ( metadata ) ;
119+
120+ // Check initial recency
121+ const initialState = store . getWorkspaceState ( workspaceId ) ;
122+ expect ( initialState . recencyTimestamp ) . toBe ( new Date ( createdAt ) . getTime ( ) ) ;
123+
124+ // Get the IPC callback to simulate messages
125+ const callback = getOnChatCallback ( ) ;
126+
127+ // Simulate CAUGHT_UP message with no history (new workspace with no messages)
128+ callback ( { type : "caught-up" } ) ;
129+
130+ // Wait for async processing
131+ await new Promise ( resolve => setTimeout ( resolve , 10 ) ) ;
132+
133+ // Recency should still be based on createdAt
134+ const stateAfterCaughtUp = store . getWorkspaceState ( workspaceId ) ;
135+ expect ( stateAfterCaughtUp . recencyTimestamp ) . toBe ( new Date ( createdAt ) . getTime ( ) ) ;
136+
137+ // Verify recency map
138+ const recency = store . getWorkspaceRecency ( ) ;
139+ expect ( recency [ workspaceId ] ) . toBe ( new Date ( createdAt ) . getTime ( ) ) ;
140+ } ) ;
141+ } ) ;
142+
143+
144+
59145 describe ( "subscription" , ( ) => {
60146 it ( "should call listener when workspace state changes" , async ( ) => {
61147 const listener = jest . fn ( ) ;
@@ -68,6 +154,7 @@ describe("WorkspaceStore", () => {
68154 projectName : "test-project" ,
69155 projectPath : "/test/project" ,
70156 namedWorkspacePath : "/test/project/test-workspace" ,
157+ createdAt : new Date ( ) . toISOString ( ) ,
71158 } ;
72159
73160 // Add workspace (should trigger IPC subscription)
@@ -95,6 +182,7 @@ describe("WorkspaceStore", () => {
95182 projectName : "test-project" ,
96183 projectPath : "/test/project" ,
97184 namedWorkspacePath : "/test/project/test-workspace" ,
185+ createdAt : new Date ( ) . toISOString ( ) ,
98186 } ;
99187
100188 store . addWorkspace ( metadata ) ;
@@ -117,6 +205,7 @@ describe("WorkspaceStore", () => {
117205 projectName : "project-1" ,
118206 projectPath : "/project-1" ,
119207 namedWorkspacePath : "/path/1" ,
208+ createdAt : new Date ( ) . toISOString ( ) ,
120209 } ;
121210
122211 const workspaceMap = new Map ( [ [ metadata1 . id , metadata1 ] ] ) ;
@@ -135,6 +224,7 @@ describe("WorkspaceStore", () => {
135224 projectName : "project-1" ,
136225 projectPath : "/project-1" ,
137226 namedWorkspacePath : "/path/1" ,
227+ createdAt : new Date ( ) . toISOString ( ) ,
138228 } ;
139229
140230 // Add workspace
@@ -151,7 +241,8 @@ describe("WorkspaceStore", () => {
151241 } ) ;
152242
153243 describe ( "getWorkspaceState" , ( ) => {
154- it ( "should return default state for new workspace" , ( ) => {
244+ it ( "should return initial state for newly added workspace" , ( ) => {
245+ createAndAddWorkspace ( store , "new-workspace" ) ;
155246 const state = store . getWorkspaceState ( "new-workspace" ) ;
156247
157248 expect ( state ) . toMatchObject ( {
@@ -161,11 +252,13 @@ describe("WorkspaceStore", () => {
161252 loading : true , // loading because not caught up
162253 cmuxMessages : [ ] ,
163254 currentModel : null ,
164- recencyTimestamp : null ,
165255 } ) ;
256+ // Should have recency based on createdAt
257+ expect ( state . recencyTimestamp ) . not . toBeNull ( ) ;
166258 } ) ;
167259
168260 it ( "should return cached state when values unchanged" , ( ) => {
261+ createAndAddWorkspace ( store , "test-workspace" ) ;
169262 const state1 = store . getWorkspaceState ( "test-workspace" ) ;
170263 const state2 = store . getWorkspaceState ( "test-workspace" ) ;
171264
@@ -197,6 +290,7 @@ describe("WorkspaceStore", () => {
197290 projectName : "test-project" ,
198291 projectPath : "/test/project" ,
199292 namedWorkspacePath : "/test/project/test-workspace" ,
293+ createdAt : new Date ( ) . toISOString ( ) ,
200294 } ;
201295
202296 store . addWorkspace ( metadata ) ;
@@ -241,6 +335,7 @@ describe("WorkspaceStore", () => {
241335 projectName : "test-project" ,
242336 projectPath : "/test/project" ,
243337 namedWorkspacePath : "/test/project/test-workspace" ,
338+ createdAt : new Date ( ) . toISOString ( ) ,
244339 } ;
245340 store . addWorkspace ( metadata ) ;
246341
@@ -268,6 +363,9 @@ describe("WorkspaceStore", () => {
268363 emitCount ++ ;
269364 } ) ;
270365
366+ // Add workspace first
367+ createAndAddWorkspace ( store , "test-workspace" ) ;
368+
271369 // Simulate what happens during render - component calls getAggregator
272370 const aggregator1 = store . getAggregator ( "test-workspace" ) ;
273371 expect ( aggregator1 ) . toBeDefined ( ) ;
@@ -292,6 +390,7 @@ describe("WorkspaceStore", () => {
292390 projectName : "test-project" ,
293391 projectPath : "/test/project" ,
294392 namedWorkspacePath : "/test/project/test-workspace" ,
393+ createdAt : new Date ( ) . toISOString ( ) ,
295394 } ;
296395 store . addWorkspace ( metadata ) ;
297396
@@ -330,6 +429,7 @@ describe("WorkspaceStore", () => {
330429 projectName : "test-project" ,
331430 projectPath : "/test/project" ,
332431 namedWorkspacePath : "/test/project/test-workspace" ,
432+ createdAt : new Date ( ) . toISOString ( ) ,
333433 } ;
334434 store . addWorkspace ( metadata ) ;
335435
@@ -360,27 +460,22 @@ describe("WorkspaceStore", () => {
360460 expect ( states1 ) . not . toBe ( states2 ) ; // Cache should be invalidated
361461 } ) ;
362462
363- it ( "invalidates getWorkspaceRecency() cache when workspace changes" , async ( ) => {
463+ it ( "maintains recency based on createdAt for new workspaces" , async ( ) => {
464+ const createdAt = new Date ( "2024-01-01T00:00:00Z" ) . toISOString ( ) ;
364465 const metadata : FrontendWorkspaceMetadata = {
365466 id : "test-workspace" ,
366467 name : "test-workspace" ,
367468 projectName : "test-project" ,
368469 projectPath : "/test/project" ,
369470 namedWorkspacePath : "/test/project/test-workspace" ,
471+ createdAt,
370472 } ;
371473 store . addWorkspace ( metadata ) ;
372474
373- const recency1 = store . getWorkspaceRecency ( ) ;
374-
375- // Trigger change (caught-up message)
376- const onChatCallback = getOnChatCallback ( ) ;
377- onChatCallback ( { type : "caught-up" } ) ;
378-
379- // Wait for queueMicrotask to complete
380- await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
381-
382- const recency2 = store . getWorkspaceRecency ( ) ;
383- expect ( recency1 ) . not . toBe ( recency2 ) ; // Cache should be invalidated
475+ const recency = store . getWorkspaceRecency ( ) ;
476+
477+ // Recency should be based on createdAt
478+ expect ( recency [ "test-workspace" ] ) . toBe ( new Date ( createdAt ) . getTime ( ) ) ;
384479 } ) ;
385480
386481 it ( "maintains cache when no changes occur" , ( ) => {
@@ -390,6 +485,7 @@ describe("WorkspaceStore", () => {
390485 projectName : "test-project" ,
391486 projectPath : "/test/project" ,
392487 namedWorkspacePath : "/test/project/test-workspace" ,
488+ createdAt : new Date ( ) . toISOString ( ) ,
393489 } ;
394490 store . addWorkspace ( metadata ) ;
395491
@@ -411,50 +507,31 @@ describe("WorkspaceStore", () => {
411507 } ) ;
412508
413509 describe ( "race conditions" , ( ) => {
414- it ( "handles IPC message for removed workspace gracefully " , async ( ) => {
510+ it ( "properly cleans up workspace on removal " , async ( ) => {
415511 const metadata : FrontendWorkspaceMetadata = {
416512 id : "test-workspace" ,
417513 name : "test-workspace" ,
418514 projectName : "test-project" ,
419515 projectPath : "/test/project" ,
420516 namedWorkspacePath : "/test/project/test-workspace" ,
517+ createdAt : new Date ( ) . toISOString ( ) ,
421518 } ;
422519 store . addWorkspace ( metadata ) ;
423520
424- const onChatCallback = getOnChatCallback ( ) ;
521+ // Verify workspace exists
522+ let allStates = store . getAllStates ( ) ;
523+ expect ( allStates . size ) . toBe ( 1 ) ;
425524
426525 // Remove workspace (clears aggregator and unsubscribes IPC)
427526 store . removeWorkspace ( "test-workspace" ) ;
428527
429- // IPC message arrives after removal - should not throw
430- // Note: In practice, the IPC unsubscribe should prevent this,
431- // but if a message was already queued, it should handle gracefully
432- const onChatCallbackTyped = onChatCallback as ( data : {
433- type : string ;
434- messageId ?: string ;
435- model ?: string ;
436- } ) => void ;
437- expect ( ( ) => {
438- // Mark as caught-up first
439- onChatCallbackTyped ( {
440- type : "caught-up" ,
441- } ) ;
442- onChatCallbackTyped ( {
443- type : "stream-start" ,
444- messageId : "msg1" ,
445- model : "claude-sonnet-4" ,
446- } ) ;
447- } ) . not . toThrow ( ) ;
448-
449- // Wait for queueMicrotask to complete
450- await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
451-
452- // The message handler will have created a new aggregator (lazy init)
453- // because getOrCreateAggregator always creates if not exists.
454- // This is actually fine - the workspace just has no IPC subscription.
455- const allStates = store . getAllStates ( ) ;
456- expect ( allStates . size ) . toBe ( 1 ) ; // Aggregator exists but not subscribed
457- expect ( allStates . get ( "test-workspace" ) ?. canInterrupt ) . toBe ( true ) ; // Stream started
528+ // Verify workspace is completely removed
529+ allStates = store . getAllStates ( ) ;
530+ expect ( allStates . size ) . toBe ( 0 ) ;
531+
532+ // Verify aggregator is gone
533+ expect ( store . getAggregator ) . toBeDefined ( ) ;
534+ expect ( ( ) => store . getAggregator ( "test-workspace" ) ) . toThrow ( / W o r k s p a c e t e s t - w o r k s p a c e n o t f o u n d / ) ;
458535 } ) ;
459536
460537 it ( "handles concurrent workspace additions" , ( ) => {
@@ -464,13 +541,15 @@ describe("WorkspaceStore", () => {
464541 projectName : "project-1" ,
465542 projectPath : "/project-1" ,
466543 namedWorkspacePath : "/path/1" ,
544+ createdAt : new Date ( ) . toISOString ( ) ,
467545 } ;
468546 const metadata2 : FrontendWorkspaceMetadata = {
469547 id : "workspace-2" ,
470548 name : "workspace-2" ,
471549 projectName : "project-2" ,
472550 projectPath : "/project-2" ,
473551 namedWorkspacePath : "/path/2" ,
552+ createdAt : new Date ( ) . toISOString ( ) ,
474553 } ;
475554
476555 // Add workspaces concurrently
@@ -490,6 +569,7 @@ describe("WorkspaceStore", () => {
490569 projectName : "test-project" ,
491570 projectPath : "/test/project" ,
492571 namedWorkspacePath : "/test/project/test-workspace" ,
572+ createdAt : new Date ( ) . toISOString ( ) ,
493573 } ;
494574 store . addWorkspace ( metadata ) ;
495575
0 commit comments