fix(web): stop "Preparing workspace" flashing on step move and refresh stale chats (#663)

This commit is contained in:
Carlos Florêncio
2026-04-20 22:48:07 +01:00
committed by GitHub
parent c31fa98e61
commit d81baf3fca
5 changed files with 138 additions and 4 deletions
@@ -390,7 +390,8 @@ export function ChatInputArea({
mcpServers={panelState.mcpServers}
onPlanModeChange={handlePlanModeChange}
isAgentBusy={isAgentBusy}
isStarting={panelState.isStarting || isMoving}
isStarting={panelState.isStarting}
isMoving={isMoving}
isSending={isSending}
onCancel={handleCancelTurn}
placeholder={placeholder}
@@ -57,6 +57,7 @@ type ChatInputContainerProps = {
onPlanModeChange: (enabled: boolean) => void;
isAgentBusy: boolean;
isStarting: boolean;
isMoving?: boolean;
isSending: boolean;
onCancel: () => void;
placeholder?: string;
@@ -241,10 +242,12 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
taskDescription,
isAgentBusy,
isStarting,
isMoving = false,
isSending,
isFailed = false,
showRequestChangesTooltip = false,
} = props;
const isBusyVisual = isStarting || isMoving;
const p = {
...props,
@@ -262,6 +265,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
sessionId,
isSending,
isStarting,
isMoving,
isFailed: p.isFailed,
needsRecovery: props.needsRecovery ?? false,
isAgentBusy,
@@ -309,7 +313,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
containerRef={s.containerRef}
height={s.height}
resizeHandleProps={s.resizeHandleProps}
isStarting={isStarting}
isStarting={isBusyVisual}
isAgentBusy={isAgentBusy}
hasClarification={s.hasClarification}
showRequestChangesTooltip={showRequestChangesTooltip}
@@ -16,6 +16,7 @@ type UseChatInputContainerParams = {
sessionId: string | null;
isSending: boolean;
isStarting: boolean;
isMoving: boolean;
isFailed: boolean;
needsRecovery: boolean;
isAgentBusy: boolean;
@@ -71,6 +72,7 @@ function getInputPlaceholder(
function computeDerivedState(params: {
isStarting: boolean;
isMoving: boolean;
isSending: boolean;
isFailed: boolean;
needsRecovery: boolean;
@@ -84,7 +86,11 @@ function computeDerivedState(params: {
hasAgentCommands: boolean;
}) {
const isDisabled =
params.isStarting || params.isSending || params.isFailed || params.needsRecovery;
params.isStarting ||
params.isMoving ||
params.isSending ||
params.isFailed ||
params.needsRecovery;
const hasClarification = !!(params.pendingClarification && params.onClarificationResolved);
const hasPendingComments = !!(
params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0
@@ -113,6 +119,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) {
sessionId,
isSending,
isStarting,
isMoving,
isFailed,
needsRecovery,
isAgentBusy,
@@ -181,6 +188,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) {
const derived = computeDerivedState({
isStarting,
isMoving,
isSending,
isFailed,
needsRecovery,
@@ -125,6 +125,26 @@ function useTerminalStateFetch(
}, [taskSessionId, taskSessionState, hasAgentMessage, connectionStatus, refs]);
}
// Silent WS disconnects (NAT timeout, laptop sleep, suspended tab) leave
// connectionStatus stuck at "connected" and no resubscribe fires. Backfill
// whenever the tab regains visibility to recover missed messages without
// requiring a page refresh.
export function useVisibilityBackfill(
taskSessionId: string | null,
store: ReturnType<typeof useAppStoreApi>,
) {
useEffect(() => {
if (!taskSessionId) return;
const onVisible = () => {
if (document.visibilityState === "visible") {
fetchAndStoreMessages(taskSessionId, store).catch(() => {});
}
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [taskSessionId, store]);
}
export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn {
const store = useAppStoreApi();
const messages = useAppStore((state) =>
@@ -206,6 +226,12 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess
});
}, [taskSessionId, connectionStatus, messages.length, store]);
// Bool flips exactly once when a freshly-adopted session leaves STARTING,
// so the subscription effect re-runs then (covering the backend race where
// session.subscribe arrives before the session is fully constructed) without
// churning on every subsequent RUNNING ↔ WAITING_FOR_INPUT transition.
const isSessionStartingOrUnknown = taskSessionState === null || taskSessionState === "STARTING";
useEffect(() => {
if (!taskSessionId || connectionStatus !== "connected") return;
const client = getWebSocketClient();
@@ -221,7 +247,9 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess
return () => {
unsubscribe();
};
}, [taskSessionId, connectionStatus, store]);
}, [taskSessionId, connectionStatus, store, isSessionStartingOrUnknown]);
useVisibilityBackfill(taskSessionId, store);
const terminalFetchRefs = useMemo(
() => ({
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, renderHook } from "@testing-library/react";
const mockRequest = vi.fn();
const mockSetMessages = vi.fn();
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: mockRequest }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: () => null,
useAppStoreApi: () => ({
getState: () => ({
messages: { bySession: {} },
setMessages: mockSetMessages,
}),
}),
}));
import { useVisibilityBackfill } from "./use-session-messages";
function setVisibility(value: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", { configurable: true, value });
document.dispatchEvent(new Event("visibilitychange"));
}
describe("useVisibilityBackfill", () => {
let store: { getState: () => unknown };
beforeEach(() => {
vi.clearAllMocks();
mockRequest.mockResolvedValue({ messages: [], has_more: false });
store = {
getState: () => ({ messages: { bySession: {} }, setMessages: mockSetMessages }),
};
});
afterEach(() => {
cleanup();
});
it("fetches when the tab becomes visible", () => {
renderHook(() => useVisibilityBackfill("sess-1", store as never));
setVisibility("visible");
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith(
"message.list",
expect.objectContaining({ session_id: "sess-1" }),
expect.any(Number),
);
});
it("does not fetch when the tab becomes hidden", () => {
renderHook(() => useVisibilityBackfill("sess-1", store as never));
setVisibility("hidden");
expect(mockRequest).not.toHaveBeenCalled();
});
it("does nothing when sessionId is null", () => {
renderHook(() => useVisibilityBackfill(null, store as never));
setVisibility("visible");
expect(mockRequest).not.toHaveBeenCalled();
});
it("removes the listener on unmount", () => {
const { unmount } = renderHook(() => useVisibilityBackfill("sess-1", store as never));
unmount();
setVisibility("visible");
expect(mockRequest).not.toHaveBeenCalled();
});
it("re-registers when sessionId changes", () => {
const { rerender } = renderHook(
({ id }: { id: string | null }) => useVisibilityBackfill(id, store as never),
{ initialProps: { id: "sess-1" } },
);
setVisibility("visible");
expect(mockRequest).toHaveBeenLastCalledWith(
"message.list",
expect.objectContaining({ session_id: "sess-1" }),
expect.any(Number),
);
rerender({ id: "sess-2" });
setVisibility("visible");
expect(mockRequest).toHaveBeenLastCalledWith(
"message.list",
expect.objectContaining({ session_id: "sess-2" }),
expect.any(Number),
);
});
});