diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index ba59dd7bdc..75e74b3011 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -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} diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index a25f8212c7..e68d1ad2ad 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -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 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, diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index b5166679ba..4b994e90c0 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -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, +) { + 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( () => ({ diff --git a/apps/web/hooks/domains/session/use-visibility-backfill.test.ts b/apps/web/hooks/domains/session/use-visibility-backfill.test.ts new file mode 100644 index 0000000000..193a14bf21 --- /dev/null +++ b/apps/web/hooks/domains/session/use-visibility-backfill.test.ts @@ -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), + ); + }); +});