Skip to content

Commit a0da41f

Browse files
committed
🤖 refactor: clean up workspace status handoff rendering
Remove the mirrored timeout from the sidebar handoff slot, clear the transient collapse state when another status row hides the slot, and fold the related spec back down so the follow-up diff stays net negative. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$19.05`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=19.05 -->
1 parent 9331413 commit a0da41f

2 files changed

Lines changed: 115 additions & 122 deletions

File tree

Lines changed: 93 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import "../../../../tests/ui/dom";
22

33
import { 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";
55
import { installDom } from "../../../../tests/ui/dom";
66
import * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore";
7-
87
import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay";
98
import { getModelName } from "@/common/utils/ai/models";
109
import { 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

3258
describe("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
});

src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,37 @@ export const WorkspaceStatusIndicator = memo<{
2929

3030
const previousPhaseRef = useRef<typeof phase>(phase);
3131
const [isCollapsingPhaseSlot, setIsCollapsingPhaseSlot] = useState(false);
32+
const phaseSlotBlocked = awaitingUserQuestion || Boolean(agentStatus);
3233
const shouldCollapsePhaseSlot =
3334
isCollapsingPhaseSlot || (previousPhaseRef.current === "starting" && phase === "streaming");
35+
const showPhaseSlot = !phaseSlotBlocked && (phase === "starting" || shouldCollapsePhaseSlot);
3436

3537
useEffect(() => {
3638
const previousPhase = previousPhaseRef.current;
3739
previousPhaseRef.current = phase;
3840

41+
if (phaseSlotBlocked) {
42+
setIsCollapsingPhaseSlot(false);
43+
return;
44+
}
45+
3946
if (previousPhase === "starting" && phase === "streaming") {
4047
setIsCollapsingPhaseSlot(true);
41-
const timeoutId = window.setTimeout(() => {
42-
setIsCollapsingPhaseSlot(false);
43-
}, 150);
44-
return () => window.clearTimeout(timeoutId);
48+
return;
49+
}
50+
51+
if (phase !== "streaming") {
52+
setIsCollapsingPhaseSlot(false);
4553
}
54+
}, [phase, phaseSlotBlocked]);
4655

47-
setIsCollapsingPhaseSlot(false);
48-
}, [phase]);
56+
// Let the CSS transition decide when the handoff slot can disappear so the JS logic
57+
// does not need a mirrored timeout that can drift from the rendered duration.
58+
const handlePhaseSlotTransitionEnd = () => {
59+
if (phase === "streaming" && isCollapsingPhaseSlot) {
60+
setIsCollapsingPhaseSlot(false);
61+
}
62+
};
4963

5064
// Show prompt when ask_user_question is pending - make it prominent
5165
if (awaitingUserQuestion) {
@@ -109,35 +123,19 @@ export const WorkspaceStatusIndicator = memo<{
109123
: (currentModel ?? pendingStreamModel ?? fallbackModel);
110124
const suffix = phase === "starting" ? "- starting..." : "- streaming...";
111125

112-
if (phase === "streaming" && !shouldCollapsePhaseSlot) {
113-
return (
114-
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
115-
{modelToShow ? (
116-
<>
117-
<span className="min-w-0 truncate">
118-
<ModelDisplay modelString={modelToShow} showTooltip={false} />
119-
</span>
120-
<span className="shrink-0 opacity-70">{suffix}</span>
121-
</>
122-
) : (
123-
<span className="min-w-0 truncate">Assistant - streaming...</span>
124-
)}
125-
</div>
126-
);
127-
}
128-
129126
return (
130127
<div className="text-muted flex min-w-0 items-center text-xs">
131128
{/* Keep the old steady-state layout, but hold the spinner slot just long enough to
132129
animate the start -> stream handoff instead of flashing the label left. */}
133-
{(phase === "starting" || shouldCollapsePhaseSlot) && (
130+
{showPhaseSlot && (
134131
<span
135132
className={
136133
phase === "starting"
137134
? "mr-1.5 inline-flex w-3 shrink-0 overflow-hidden opacity-100"
138135
: "mr-0 inline-flex w-0 shrink-0 overflow-hidden opacity-0 transition-[margin,width,opacity] duration-150 ease-out"
139136
}
140137
data-phase-slot
138+
onTransitionEnd={handlePhaseSlotTransitionEnd}
141139
>
142140
<Loader2
143141
aria-hidden="true"

0 commit comments

Comments
 (0)