Skip to content

Commit 58157b3

Browse files
committed
🤖 refactor: clean up workspace status handoff rendering
Remove the mirrored timeout from the sidebar handoff slot, and clear the transient collapse state when another status row hides the slot before its transition can finish. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$16.21`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=16.21 -->
1 parent 9331413 commit 58157b3

2 files changed

Lines changed: 87 additions & 25 deletions

File tree

src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.test.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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";
77

@@ -142,8 +142,72 @@ describe("WorkspaceStatusIndicator", () => {
142142
expect(getPhaseSlot()?.getAttribute("class") ?? "").toContain("w-0");
143143
expect(getPhaseSlot()?.getAttribute("class") ?? "").toContain("mr-0");
144144
expect(getPhaseIcon()?.getAttribute("class") ?? "").not.toContain("animate-spin");
145+
fireEvent.transitionEnd(getPhaseSlot()!, { propertyName: "width" });
146+
147+
expect(getPhaseSlot()).toBeNull();
145148
expect(getModelDisplay()?.textContent ?? "").toContain(pendingDisplayName);
146149
expect(getModelDisplay()?.textContent ?? "").not.toContain(fallbackDisplayName);
147150
expect(view.container.textContent?.toLowerCase()).toContain("streaming");
148151
});
152+
153+
test("does not leak the collapsed handoff slot after agent status hides it", () => {
154+
const pendingModel = "openai:gpt-4o-mini";
155+
const fallbackModel = "anthropic:claude-sonnet-4-5";
156+
const pendingDisplayName = formatModelDisplayName(getModelName(pendingModel));
157+
const state: WorkspaceStoreModule.WorkspaceSidebarState = {
158+
canInterrupt: false,
159+
isStarting: true,
160+
awaitingUserQuestion: false,
161+
lastAbortReason: null,
162+
currentModel: null,
163+
pendingStreamModel: pendingModel,
164+
recencyTimestamp: null,
165+
loadedSkills: [],
166+
skillLoadErrors: [],
167+
agentStatus: undefined,
168+
terminalActiveCount: 0,
169+
terminalSessionCount: 0,
170+
};
171+
spyOn(WorkspaceStoreModule, "useWorkspaceSidebarState").mockImplementation(() => state);
172+
173+
const view = render(
174+
<WorkspaceStatusIndicator
175+
workspaceId="workspace-status-handoff-starting"
176+
fallbackModel={fallbackModel}
177+
/>
178+
);
179+
180+
expect(
181+
view.container.querySelector("[data-phase-slot]")?.getAttribute("class") ?? ""
182+
).toContain("w-3");
183+
184+
state.isStarting = false;
185+
state.canInterrupt = true;
186+
state.currentModel = pendingModel;
187+
state.pendingStreamModel = null;
188+
state.agentStatus = { emoji: "🔄", message: "Run checks" };
189+
view.rerender(
190+
<WorkspaceStatusIndicator
191+
workspaceId="workspace-status-handoff-status"
192+
fallbackModel={fallbackModel}
193+
/>
194+
);
195+
196+
expect(view.container.querySelector("[data-phase-slot]")).toBeNull();
197+
expect(view.container.textContent ?? "").toContain("Run checks");
198+
199+
state.agentStatus = undefined;
200+
view.rerender(
201+
<WorkspaceStatusIndicator
202+
workspaceId="workspace-status-handoff-streaming"
203+
fallbackModel={fallbackModel}
204+
/>
205+
);
206+
207+
expect(view.container.querySelector("[data-phase-slot]")).toBeNull();
208+
expect(view.container.querySelector("[data-model-display]")?.textContent ?? "").toContain(
209+
pendingDisplayName
210+
);
211+
expect(view.container.textContent?.toLowerCase()).toContain("streaming");
212+
});
149213
});

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)