11import "../../../../tests/ui/dom" ;
22
33import { afterEach , beforeEach , describe , expect , mock , spyOn , test } from "bun:test" ;
4- import { cleanup , render } from "@testing-library/react" ;
4+ import { cleanup , fireEvent , render } from "@testing-library/react" ;
55import { installDom } from "../../../../tests/ui/dom" ;
66import * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore" ;
7-
87import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay" ;
98import { getModelName } from "@/common/utils/ai/models" ;
109import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator" ;
1110
12- function mockSidebarState (
11+ const FALLBACK_MODEL = "anthropic:claude-sonnet-4-5" ;
12+ const PENDING_MODEL = "openai:gpt-4o-mini" ;
13+ const PENDING_DISPLAY_NAME = formatModelDisplayName ( getModelName ( PENDING_MODEL ) ) ;
14+
15+ function createSidebarState (
1316 overrides : Partial < WorkspaceStoreModule . WorkspaceSidebarState > = { }
14- ) : void {
15- spyOn ( WorkspaceStoreModule , "useWorkspaceSidebarState" ) . mockImplementation ( ( ) => ( {
17+ ) : WorkspaceStoreModule . WorkspaceSidebarState {
18+ return {
1619 canInterrupt : false ,
1720 isStarting : false ,
1821 awaitingUserQuestion : false ,
@@ -26,7 +29,30 @@ function mockSidebarState(
2629 terminalActiveCount : 0 ,
2730 terminalSessionCount : 0 ,
2831 ...overrides ,
29- } ) ) ;
32+ } ;
33+ }
34+
35+ function renderIndicator (
36+ overrides : Partial < WorkspaceStoreModule . WorkspaceSidebarState > = { } ,
37+ workspaceId = "workspace"
38+ ) {
39+ const state = createSidebarState ( overrides ) ;
40+ spyOn ( WorkspaceStoreModule , "useWorkspaceSidebarState" ) . mockImplementation ( ( ) => state ) ;
41+ const view = render (
42+ < WorkspaceStatusIndicator workspaceId = { workspaceId } fallbackModel = { FALLBACK_MODEL } />
43+ ) ;
44+ return {
45+ state,
46+ view,
47+ rerender ( nextWorkspaceId = workspaceId ) {
48+ view . rerender (
49+ < WorkspaceStatusIndicator workspaceId = { nextWorkspaceId } fallbackModel = { FALLBACK_MODEL } />
50+ ) ;
51+ } ,
52+ phaseSlot : ( ) => view . container . querySelector ( "[data-phase-slot]" ) ,
53+ phaseIcon : ( ) => view . container . querySelector ( "[data-phase-slot] svg" ) ,
54+ modelDisplay : ( ) => view . container . querySelector ( "[data-model-display]" ) ,
55+ } ;
3056}
3157
3258describe ( "WorkspaceStatusIndicator" , ( ) => {
@@ -43,107 +69,76 @@ describe("WorkspaceStatusIndicator", () => {
4369 mock . restore ( ) ;
4470 } ) ;
4571
46- test ( "keeps unfinished todo status static once the stream is idle" , ( ) => {
47- mockSidebarState ( {
48- agentStatus : { emoji : "🔄" , message : "Run checks" } ,
72+ for ( const [ name , overrides , spins ] of [
73+ [
74+ "keeps unfinished todo status static once the stream is idle" ,
75+ { agentStatus : { emoji : "🔄" , message : "Run checks" } } ,
76+ false ,
77+ ] ,
78+ [
79+ "keeps refresh-style status animated while a stream is still active" ,
80+ { canInterrupt : true , agentStatus : { emoji : "🔄" , message : "Run checks" } } ,
81+ true ,
82+ ] ,
83+ ] satisfies Array < [ string , Partial < WorkspaceStoreModule . WorkspaceSidebarState > , boolean ] > ) {
84+ test ( name , ( ) => {
85+ const { view } = renderIndicator ( overrides , name ) ;
86+ const className = view . container . querySelector ( "svg" ) ?. getAttribute ( "class" ) ?? "" ;
87+ expect ( view . container . querySelector ( "svg" ) ) . toBeTruthy ( ) ;
88+ expect ( className . includes ( "animate-spin" ) ) . toBe ( spins ) ;
4989 } ) ;
90+ }
5091
51- const view = render (
52- < WorkspaceStatusIndicator workspaceId = "workspace-idle" fallbackModel = "openai:gpt-5.4" />
92+ test ( "keeps the model label anchored when starting hands off to streaming" , ( ) => {
93+ const indicator = renderIndicator (
94+ { isStarting : true , pendingStreamModel : PENDING_MODEL } ,
95+ "workspace-phase-shift-starting"
5396 ) ;
54-
55- const icon = view . container . querySelector ( "svg ") ;
56- expect ( icon ) . toBeTruthy ( ) ;
57- expect ( icon ?. getAttribute ( "class" ) ?? "" ) . not . toContain ( "animate-spin" ) ;
58- } ) ;
59-
60- test ( "keeps refresh-style status animated while a stream is still active" , ( ) => {
61- mockSidebarState ( {
97+ expect ( indicator . phaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "w-3" ) ;
98+ expect ( indicator . phaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "mr-1.5 ") ;
99+ expect ( indicator . phaseIcon ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "animate-spin" ) ;
100+ expect ( indicator . modelDisplay ( ) ?. textContent ?? "" ) . toContain ( PENDING_DISPLAY_NAME ) ;
101+ expect ( indicator . view . container . textContent ?. toLowerCase ( ) ) . toContain ( "starting" ) ;
102+
103+ Object . assign ( indicator . state , {
104+ isStarting : false ,
62105 canInterrupt : true ,
63- agentStatus : { emoji : "🔄" , message : "Run checks" } ,
106+ currentModel : PENDING_MODEL ,
107+ pendingStreamModel : null ,
64108 } ) ;
109+ indicator . rerender ( "workspace-phase-shift-streaming" ) ;
110+
111+ expect ( indicator . phaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "w-0" ) ;
112+ expect ( indicator . phaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "mr-0" ) ;
113+ expect ( indicator . phaseIcon ( ) ?. getAttribute ( "class" ) ?? "" ) . not . toContain ( "animate-spin" ) ;
114+ fireEvent . transitionEnd ( indicator . phaseSlot ( ) ! , { propertyName : "width" } ) ;
115+ expect ( indicator . phaseSlot ( ) ) . toBeNull ( ) ;
116+ expect ( indicator . modelDisplay ( ) ?. textContent ?? "" ) . toContain ( PENDING_DISPLAY_NAME ) ;
117+ expect ( indicator . view . container . textContent ?. toLowerCase ( ) ) . toContain ( "streaming" ) ;
118+ } ) ;
65119
66- const view = render (
67- < WorkspaceStatusIndicator workspaceId = "workspace-streaming" fallbackModel = "openai:gpt-5.4" />
120+ test ( "does not leak the collapsed handoff slot after agent status hides it" , ( ) => {
121+ const indicator = renderIndicator (
122+ { isStarting : true , pendingStreamModel : PENDING_MODEL } ,
123+ "workspace-status-handoff-starting"
68124 ) ;
125+ expect ( indicator . phaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "w-3" ) ;
69126
70- const icon = view . container . querySelector ( "svg" ) ;
71- expect ( icon ) . toBeTruthy ( ) ;
72- expect ( icon ?. getAttribute ( "class" ) ?? "" ) . toContain ( "animate-spin" ) ;
73- } ) ;
74-
75- test ( "keeps the steady streaming layout free of the transient handoff slot" , ( ) => {
76- mockSidebarState ( {
127+ Object . assign ( indicator . state , {
128+ isStarting : false ,
77129 canInterrupt : true ,
78- currentModel : "openai:gpt-4o-mini" ,
130+ currentModel : PENDING_MODEL ,
131+ pendingStreamModel : null ,
132+ agentStatus : { emoji : "🔄" , message : "Run checks" } ,
79133 } ) ;
80-
81- const view = render (
82- < WorkspaceStatusIndicator
83- workspaceId = "workspace-live-stream"
84- fallbackModel = "anthropic:claude-sonnet-4-5"
85- />
86- ) ;
87-
88- expect ( view . container . querySelector ( "[data-phase-slot]" ) ) . toBeNull ( ) ;
89- expect ( view . container . textContent ?. toLowerCase ( ) ) . toContain ( "streaming" ) ;
90- } ) ;
91-
92- test ( "keeps the model label anchored when starting hands off to streaming" , ( ) => {
93- const pendingModel = "openai:gpt-4o-mini" ;
94- const fallbackModel = "anthropic:claude-sonnet-4-5" ;
95- const pendingDisplayName = formatModelDisplayName ( getModelName ( pendingModel ) ) ;
96- const fallbackDisplayName = formatModelDisplayName ( getModelName ( fallbackModel ) ) ;
97- const state : WorkspaceStoreModule . WorkspaceSidebarState = {
98- canInterrupt : false ,
99- isStarting : true ,
100- awaitingUserQuestion : false ,
101- lastAbortReason : null ,
102- currentModel : null ,
103- pendingStreamModel : pendingModel ,
104- recencyTimestamp : null ,
105- loadedSkills : [ ] ,
106- skillLoadErrors : [ ] ,
107- agentStatus : undefined ,
108- terminalActiveCount : 0 ,
109- terminalSessionCount : 0 ,
110- } ;
111- spyOn ( WorkspaceStoreModule , "useWorkspaceSidebarState" ) . mockImplementation ( ( ) => state ) ;
112-
113- const view = render (
114- < WorkspaceStatusIndicator
115- workspaceId = "workspace-phase-shift-starting"
116- fallbackModel = { fallbackModel }
117- />
118- ) ;
119-
120- const getPhaseSlot = ( ) => view . container . querySelector ( "[data-phase-slot]" ) ;
121- const getPhaseIcon = ( ) => getPhaseSlot ( ) ?. querySelector ( "svg" ) ;
122- const getModelDisplay = ( ) => view . container . querySelector ( "[data-model-display]" ) ;
123-
124- expect ( getPhaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "w-3" ) ;
125- expect ( getPhaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "mr-1.5" ) ;
126- expect ( getPhaseIcon ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "animate-spin" ) ;
127- expect ( getModelDisplay ( ) ?. textContent ?? "" ) . toContain ( pendingDisplayName ) ;
128- expect ( getModelDisplay ( ) ?. textContent ?? "" ) . not . toContain ( fallbackDisplayName ) ;
129- expect ( view . container . textContent ?. toLowerCase ( ) ) . toContain ( "starting" ) ;
130-
131- state . isStarting = false ;
132- state . canInterrupt = true ;
133- state . currentModel = pendingModel ;
134- state . pendingStreamModel = null ;
135- view . rerender (
136- < WorkspaceStatusIndicator
137- workspaceId = "workspace-phase-shift-streaming"
138- fallbackModel = { fallbackModel }
139- />
140- ) ;
141-
142- expect ( getPhaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "w-0" ) ;
143- expect ( getPhaseSlot ( ) ?. getAttribute ( "class" ) ?? "" ) . toContain ( "mr-0" ) ;
144- expect ( getPhaseIcon ( ) ?. getAttribute ( "class" ) ?? "" ) . not . toContain ( "animate-spin" ) ;
145- expect ( getModelDisplay ( ) ?. textContent ?? "" ) . toContain ( pendingDisplayName ) ;
146- expect ( getModelDisplay ( ) ?. textContent ?? "" ) . not . toContain ( fallbackDisplayName ) ;
147- expect ( view . container . textContent ?. toLowerCase ( ) ) . toContain ( "streaming" ) ;
134+ indicator . rerender ( "workspace-status-handoff-status" ) ;
135+ expect ( indicator . phaseSlot ( ) ) . toBeNull ( ) ;
136+ expect ( indicator . view . container . textContent ?? "" ) . toContain ( "Run checks" ) ;
137+
138+ indicator . state . agentStatus = undefined ;
139+ indicator . rerender ( "workspace-status-handoff-streaming" ) ;
140+ expect ( indicator . phaseSlot ( ) ) . toBeNull ( ) ;
141+ expect ( indicator . modelDisplay ( ) ?. textContent ?? "" ) . toContain ( PENDING_DISPLAY_NAME ) ;
142+ expect ( indicator . view . container . textContent ?. toLowerCase ( ) ) . toContain ( "streaming" ) ;
148143 } ) ;
149144} ) ;
0 commit comments