From 596982052f6ce4e16faf6644486d6ed0e1f5a684 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 5 Apr 2026 16:56:03 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20pre-stream=20?= =?UTF-8?q?workspace=20status=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the workspace sidebar status label anchored through the pre-stream handoff by reserving the loader slot and by reusing the pending requested model before stream-start. Add component and store coverage for the new pending-model sidebar path. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$5.46`_ --- .../AgentListItem/AgentListItem.test.tsx | 1 + .../WorkspaceStatusIndicator.test.tsx | 78 ++++++++++++++++ .../WorkspaceStatusIndicator.tsx | 92 ++++++++++++++++--- src/browser/stores/WorkspaceStore.test.ts | 50 +++++++++- src/browser/stores/WorkspaceStore.ts | 5 + 5 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/browser/components/AgentListItem/AgentListItem.test.tsx b/src/browser/components/AgentListItem/AgentListItem.test.tsx index 8acf098498..891b75cac9 100644 --- a/src/browser/components/AgentListItem/AgentListItem.test.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.test.tsx @@ -67,6 +67,7 @@ function createWorkspaceSidebarState( awaitingUserQuestion: false, lastAbortReason: null, currentModel: null, + pendingStreamModel: null, recencyTimestamp: null, loadedSkills: [], skillLoadErrors: [], diff --git a/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.test.tsx b/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.test.tsx index ff757712ba..770f3f30e9 100644 --- a/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.test.tsx +++ b/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.test.tsx @@ -5,6 +5,8 @@ import { cleanup, render } from "@testing-library/react"; import { installDom } from "../../../../tests/ui/dom"; import * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore"; +import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay"; +import { getModelName } from "@/common/utils/ai/models"; import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator"; function mockSidebarState( @@ -16,6 +18,7 @@ function mockSidebarState( awaitingUserQuestion: false, lastAbortReason: null, currentModel: null, + pendingStreamModel: null, recencyTimestamp: null, loadedSkills: [], skillLoadErrors: [], @@ -68,4 +71,79 @@ describe("WorkspaceStatusIndicator", () => { expect(icon).toBeTruthy(); expect(icon?.getAttribute("class") ?? "").toContain("animate-spin"); }); + + test("keeps the steady streaming layout free of the transient handoff slot", () => { + mockSidebarState({ + canInterrupt: true, + currentModel: "openai:gpt-4o-mini", + }); + + const view = render( + + ); + + expect(view.container.querySelector("[data-phase-slot]")).toBeNull(); + expect(view.container.textContent?.toLowerCase()).toContain("streaming"); + }); + + test("keeps the model label anchored when starting hands off to streaming", () => { + const pendingModel = "openai:gpt-4o-mini"; + const fallbackModel = "anthropic:claude-sonnet-4-5"; + const pendingDisplayName = formatModelDisplayName(getModelName(pendingModel)); + const fallbackDisplayName = formatModelDisplayName(getModelName(fallbackModel)); + const state: WorkspaceStoreModule.WorkspaceSidebarState = { + canInterrupt: false, + isStarting: true, + awaitingUserQuestion: false, + lastAbortReason: null, + currentModel: null, + pendingStreamModel: pendingModel, + recencyTimestamp: null, + loadedSkills: [], + skillLoadErrors: [], + agentStatus: undefined, + terminalActiveCount: 0, + terminalSessionCount: 0, + }; + spyOn(WorkspaceStoreModule, "useWorkspaceSidebarState").mockImplementation(() => state); + + const view = render( + + ); + + const getPhaseSlot = () => view.container.querySelector("[data-phase-slot]"); + const getPhaseIcon = () => getPhaseSlot()?.querySelector("svg"); + const getModelDisplay = () => view.container.querySelector("[data-model-display]"); + + expect(getPhaseSlot()?.getAttribute("class") ?? "").toContain("w-3"); + expect(getPhaseSlot()?.getAttribute("class") ?? "").toContain("mr-1.5"); + expect(getPhaseIcon()?.getAttribute("class") ?? "").toContain("animate-spin"); + expect(getModelDisplay()?.textContent ?? "").toContain(pendingDisplayName); + expect(getModelDisplay()?.textContent ?? "").not.toContain(fallbackDisplayName); + expect(view.container.textContent?.toLowerCase()).toContain("starting"); + + state.isStarting = false; + state.canInterrupt = true; + state.currentModel = pendingModel; + state.pendingStreamModel = null; + view.rerender( + + ); + + expect(getPhaseSlot()?.getAttribute("class") ?? "").toContain("w-0"); + expect(getPhaseSlot()?.getAttribute("class") ?? "").toContain("mr-0"); + expect(getPhaseIcon()?.getAttribute("class") ?? "").not.toContain("animate-spin"); + expect(getModelDisplay()?.textContent ?? "").toContain(pendingDisplayName); + expect(getModelDisplay()?.textContent ?? "").not.toContain(fallbackDisplayName); + expect(view.container.textContent?.toLowerCase()).toContain("streaming"); + }); }); diff --git a/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx b/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx index 20752786d7..5879644e23 100644 --- a/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx +++ b/src/browser/components/WorkspaceStatusIndicator/WorkspaceStatusIndicator.tsx @@ -2,7 +2,7 @@ import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; import { ModelDisplay } from "@/browser/features/Messages/ModelDisplay"; import { EmojiIcon } from "@/browser/components/icons/EmojiIcon/EmojiIcon"; import { CircleHelp, ExternalLinkIcon, Loader2 } from "lucide-react"; -import { memo } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; export const WorkspaceStatusIndicator = memo<{ @@ -12,8 +12,40 @@ export const WorkspaceStatusIndicator = memo<{ * a prop so this component doesn't need to subscribe to the full WorkspaceContext. */ isCreating?: boolean; }>(({ workspaceId, fallbackModel, isCreating }) => { - const { canInterrupt, isStarting, awaitingUserQuestion, currentModel, agentStatus } = - useWorkspaceSidebarState(workspaceId); + const { + canInterrupt, + isStarting, + awaitingUserQuestion, + currentModel, + pendingStreamModel, + agentStatus, + } = useWorkspaceSidebarState(workspaceId); + + const phase: "starting" | "streaming" | null = canInterrupt + ? "streaming" + : isStarting || isCreating + ? "starting" + : null; + + const previousPhaseRef = useRef(phase); + const [isCollapsingPhaseSlot, setIsCollapsingPhaseSlot] = useState(false); + const shouldCollapsePhaseSlot = + isCollapsingPhaseSlot || (previousPhaseRef.current === "starting" && phase === "streaming"); + + useEffect(() => { + const previousPhase = previousPhaseRef.current; + previousPhaseRef.current = phase; + + if (previousPhase === "starting" && phase === "streaming") { + setIsCollapsingPhaseSlot(true); + const timeoutId = window.setTimeout(() => { + setIsCollapsingPhaseSlot(false); + }, 150); + return () => window.clearTimeout(timeoutId); + } + + setIsCollapsingPhaseSlot(false); + }, [phase]); // Show prompt when ask_user_question is pending - make it prominent if (awaitingUserQuestion) { @@ -67,31 +99,61 @@ export const WorkspaceStatusIndicator = memo<{ ); } - const phase: "starting" | "streaming" | null = canInterrupt - ? "streaming" - : isStarting || isCreating - ? "starting" - : null; - if (!phase) { return null; } - const modelToShow = canInterrupt ? (currentModel ?? fallbackModel) : fallbackModel; + const modelToShow = + phase === "starting" + ? (pendingStreamModel ?? fallbackModel) + : (currentModel ?? pendingStreamModel ?? fallbackModel); const suffix = phase === "starting" ? "- starting..." : "- streaming..."; + if (phase === "streaming" && !shouldCollapsePhaseSlot) { + return ( +
+ {modelToShow ? ( + <> + + + + {suffix} + + ) : ( + Assistant - streaming... + )} +
+ ); + } + return ( -
- {phase === "starting" && ( -