fix(web): stop "Preparing workspace" flashing on step move and refresh stale chats (#663)
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user