diff --git a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist index 19fc573c..f132f0d0 100644 --- a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist +++ b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist @@ -49,4 +49,4 @@ NSMicrophoneUsageDescription Allow access to the microphone for dictation. - + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 94e29980..ac8dddf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,7 +66,10 @@ import { useWorkspaceFileListing } from "@app/hooks/useWorkspaceFileListing"; import { useGitBranches } from "@/features/git/hooks/useGitBranches"; import { useBranchSwitcher } from "@/features/git/hooks/useBranchSwitcher"; import { useBranchSwitcherShortcut } from "@/features/git/hooks/useBranchSwitcherShortcut"; -import { useWorkspaceRefreshOnFocus } from "@/features/workspaces/hooks/useWorkspaceRefreshOnFocus"; +import { + REMOTE_WORKSPACE_REFRESH_INTERVAL_MS, + useWorkspaceRefreshOnFocus, +} from "@/features/workspaces/hooks/useWorkspaceRefreshOnFocus"; import { useWorkspaceRestore } from "@/features/workspaces/hooks/useWorkspaceRestore"; import { useRenameWorktreePrompt } from "@/features/workspaces/hooks/useRenameWorktreePrompt"; import { useLayoutController } from "@app/hooks/useLayoutController"; @@ -1691,7 +1694,9 @@ function MainApp() { useWorkspaceRefreshOnFocus({ workspaces, refreshWorkspaces, - listThreadsForWorkspace + listThreadsForWorkspace, + backendMode: appSettings.backendMode, + pollIntervalMs: REMOTE_WORKSPACE_REFRESH_INTERVAL_MS, }); useRemoteThreadRefreshOnFocus({ diff --git a/src/features/app/components/Sidebar.test.tsx b/src/features/app/components/Sidebar.test.tsx index 436e019a..ac41e4f4 100644 --- a/src/features/app/components/Sidebar.test.tsx +++ b/src/features/app/components/Sidebar.test.tsx @@ -242,4 +242,51 @@ describe("Sidebar", () => { fireEvent.click(draftRow); expect(onSelectWorkspace).toHaveBeenCalledWith("ws-1"); }); + + it("does not show a workspace activity indicator when a thread is processing", () => { + render( + , + ); + + const indicator = screen.queryByTitle("Streaming updates in progress"); + expect(indicator).toBeNull(); + }); }); diff --git a/src/features/app/hooks/useRemoteThreadLiveConnection.test.tsx b/src/features/app/hooks/useRemoteThreadLiveConnection.test.tsx new file mode 100644 index 00000000..66ffb309 --- /dev/null +++ b/src/features/app/hooks/useRemoteThreadLiveConnection.test.tsx @@ -0,0 +1,531 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useRemoteThreadLiveConnection } from "./useRemoteThreadLiveConnection"; + +const appServerListeners = new Set<(event: any) => void>(); +const subscribeAppServerEventsMock = vi.fn((listener: (event: any) => void) => { + appServerListeners.add(listener); + return () => { + appServerListeners.delete(listener); + }; +}); + +const threadLiveSubscribeMock = vi.fn().mockResolvedValue(undefined); +const threadLiveUnsubscribeMock = vi.fn().mockResolvedValue(undefined); +const pushErrorToastMock = vi.fn(); + +vi.mock("@services/events", () => ({ + subscribeAppServerEvents: (listener: (event: any) => void) => + subscribeAppServerEventsMock(listener), +})); + +vi.mock("@services/tauri", () => ({ + threadLiveSubscribe: (...args: any[]) => threadLiveSubscribeMock(...args), + threadLiveUnsubscribe: (...args: any[]) => threadLiveUnsubscribeMock(...args), +})); + +vi.mock("@services/toasts", () => ({ + pushErrorToast: (...args: any[]) => pushErrorToastMock(...args), +})); + +vi.mock("@utils/appServerEvents", () => ({ + getAppServerRawMethod: (event: any) => event.method ?? null, + getAppServerParams: (event: any) => event.params ?? {}, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + listen: vi.fn().mockResolvedValue(() => {}), + }), +})); + +describe("useRemoteThreadLiveConnection", () => { + let visibilityState: DocumentVisibilityState; + let hasFocus: boolean; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-19T00:00:00.000Z")); + visibilityState = "visible"; + hasFocus = true; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + Object.defineProperty(document, "hasFocus", { + configurable: true, + value: () => hasFocus, + }); + appServerListeners.clear(); + subscribeAppServerEventsMock.mockClear(); + threadLiveSubscribeMock.mockClear(); + threadLiveUnsubscribeMock.mockClear(); + pushErrorToastMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not reconnect during normal idle period without detach signal", async () => { + const refreshThread = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + activeThreadId: "thread-1", + refreshThread, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + expect(refreshThread).toHaveBeenCalledTimes(1); + + const heartbeatEvent = { + workspace_id: "ws-1", + method: "thread/live_heartbeat", + params: { threadId: "thread-1" }, + }; + await act(async () => { + for (const listener of appServerListeners) { + listener(heartbeatEvent); + } + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(25_000); + await Promise.resolve(); + }); + + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + expect(threadLiveUnsubscribeMock).toHaveBeenCalledTimes(0); + expect(refreshThread).toHaveBeenCalledTimes(1); + expect(pushErrorToastMock).not.toHaveBeenCalled(); + }); + + it("reconnects when thread live stream detaches while visible", async () => { + const refreshThread = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + activeThreadId: "thread-1", + refreshThread, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + + await act(async () => { + for (const listener of appServerListeners) { + listener({ + workspace_id: "ws-1", + method: "thread/live_detached", + params: { threadId: "thread-1" }, + }); + } + await Promise.resolve(); + }); + + expect(threadLiveSubscribeMock.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(refreshThread.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it("does not reconnect detached stream when window is not focused", async () => { + const refreshThread = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + activeThreadId: "thread-1", + refreshThread, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + + hasFocus = false; + await act(async () => { + for (const listener of appServerListeners) { + listener({ + workspace_id: "ws-1", + method: "thread/live_detached", + params: { threadId: "thread-1" }, + }); + } + await Promise.resolve(); + }); + + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + }); + + it("promotes polling state to live on thread activity without heartbeat", async () => { + const refreshThread = vi.fn().mockResolvedValue(undefined); + const workspace = { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }; + + const { result } = renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: workspace, + activeThreadId: "thread-1", + refreshThread, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.connectionState).toBe("polling"); + + await act(async () => { + for (const listener of appServerListeners) { + listener({ + workspace_id: "ws-1", + method: "item/started", + params: { threadId: "thread-1" }, + }); + } + await Promise.resolve(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.connectionState).toBe("live"); + }); + + it("cleans up stale reconnect subscribe when sequence advances", async () => { + let resolveFirstSubscribe: (() => void) | null = null; + const firstSubscribe = new Promise((resolve) => { + resolveFirstSubscribe = resolve; + }); + threadLiveSubscribeMock + .mockImplementationOnce(() => firstSubscribe) + .mockResolvedValue(undefined); + const refreshThread = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + activeThreadId: null, + refreshThread, + }), + ); + + let firstReconnectPromise: Promise = Promise.resolve(false); + await act(async () => { + firstReconnectPromise = result.current.reconnectLive("ws-1", "thread-1", { + runResume: false, + }); + await Promise.resolve(); + }); + + await act(async () => { + await result.current.reconnectLive("ws-1", "thread-2", { runResume: false }); + await Promise.resolve(); + }); + + await act(async () => { + resolveFirstSubscribe?.(); + await firstReconnectPromise; + await Promise.resolve(); + }); + + expect(threadLiveUnsubscribeMock).toHaveBeenCalledWith("ws-1", "thread-1"); + }); + + it("coalesces same-key reconnect while subscribe is in flight", async () => { + let resolveFirstSubscribe: (() => void) | null = null; + const firstSubscribe = new Promise((resolve) => { + resolveFirstSubscribe = resolve; + }); + threadLiveSubscribeMock + .mockImplementationOnce(() => firstSubscribe) + .mockResolvedValue(undefined); + const refreshThread = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + activeThreadId: null, + refreshThread, + }), + ); + + let firstReconnectPromise: Promise = Promise.resolve(false); + let secondReconnectPromise: Promise = Promise.resolve(false); + await act(async () => { + firstReconnectPromise = result.current.reconnectLive("ws-1", "thread-1", { + runResume: false, + }); + await Promise.resolve(); + }); + + await act(async () => { + secondReconnectPromise = result.current.reconnectLive("ws-1", "thread-1", { + runResume: false, + }); + await Promise.resolve(); + }); + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirstSubscribe?.(); + await firstReconnectPromise; + await secondReconnectPromise; + await Promise.resolve(); + }); + + expect(threadLiveUnsubscribeMock).not.toHaveBeenCalled(); + }); + + it("cancels in-flight reconnect attempt when window blurs", async () => { + let resolveFirstSubscribe: (() => void) | null = null; + const firstSubscribe = new Promise((resolve) => { + resolveFirstSubscribe = resolve; + }); + threadLiveSubscribeMock.mockImplementationOnce(() => firstSubscribe); + const refreshThread = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + activeThreadId: null, + refreshThread, + }), + ); + + await act(async () => { + result.current.reconnectLive("ws-1", "thread-1", { runResume: false }); + await Promise.resolve(); + }); + + await act(async () => { + window.dispatchEvent(new Event("blur")); + await Promise.resolve(); + }); + + await act(async () => { + resolveFirstSubscribe?.(); + await Promise.resolve(); + }); + + expect(threadLiveUnsubscribeMock).toHaveBeenCalledWith("ws-1", "thread-1"); + }); + + it("starts a fresh reconnect after blur cancels same-key in-flight attempt", async () => { + let resolveFirstSubscribe: (() => void) | null = null; + const firstSubscribe = new Promise((resolve) => { + resolveFirstSubscribe = resolve; + }); + threadLiveSubscribeMock + .mockImplementationOnce(() => firstSubscribe) + .mockResolvedValue(undefined); + const refreshThread = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + activeThreadId: null, + refreshThread, + }), + ); + + let firstReconnectPromise: Promise = Promise.resolve(false); + await act(async () => { + firstReconnectPromise = result.current.reconnectLive("ws-1", "thread-1", { + runResume: false, + }); + await Promise.resolve(); + }); + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + + await act(async () => { + window.dispatchEvent(new Event("blur")); + await Promise.resolve(); + }); + + let secondReconnectPromise: Promise = Promise.resolve(false); + await act(async () => { + secondReconnectPromise = result.current.reconnectLive("ws-1", "thread-1", { + runResume: false, + }); + await Promise.resolve(); + }); + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(2); + + await act(async () => { + await expect(secondReconnectPromise).resolves.toBe(true); + }); + + await act(async () => { + resolveFirstSubscribe?.(); + await expect(firstReconnectPromise).resolves.toBe(false); + await Promise.resolve(); + }); + }); + + it("does not reconnect when workspace object identity changes but key is unchanged", async () => { + const refreshThread = vi.fn().mockResolvedValue(undefined); + const firstWorkspace = { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }; + + const { rerender } = renderHook( + ({ workspace }) => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: workspace, + activeThreadId: "thread-1", + refreshThread, + }), + { + initialProps: { workspace: firstWorkspace }, + }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + expect(refreshThread).toHaveBeenCalledTimes(1); + + const secondWorkspace = { + id: "ws-1", + name: "Workspace (renamed)", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }; + + await act(async () => { + rerender({ workspace: secondWorkspace }); + await Promise.resolve(); + }); + + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + expect(threadLiveUnsubscribeMock).toHaveBeenCalledTimes(0); + expect(refreshThread).toHaveBeenCalledTimes(1); + }); + + it("ignores self-triggered detached event during dedupe reconnect", async () => { + const refreshThread = vi.fn().mockResolvedValue(undefined); + const workspace = { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }; + + const { result } = renderHook(() => + useRemoteThreadLiveConnection({ + backendMode: "remote", + activeWorkspace: workspace, + activeThreadId: "thread-1", + refreshThread, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(1); + expect(refreshThread).toHaveBeenCalledTimes(1); + + threadLiveUnsubscribeMock.mockImplementationOnce(async (workspaceId, threadId) => { + for (const listener of appServerListeners) { + listener({ + workspace_id: workspaceId, + method: "thread/live_detached", + params: { threadId }, + }); + } + }); + + await act(async () => { + await result.current.reconnectLive("ws-1", "thread-1", { runResume: false }); + await Promise.resolve(); + }); + + expect(threadLiveUnsubscribeMock).toHaveBeenCalledTimes(1); + expect(threadLiveSubscribeMock).toHaveBeenCalledTimes(2); + expect(refreshThread).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/app/hooks/useRemoteThreadLiveConnection.ts b/src/features/app/hooks/useRemoteThreadLiveConnection.ts index 70bde85c..68db37c8 100644 --- a/src/features/app/hooks/useRemoteThreadLiveConnection.ts +++ b/src/features/app/hooks/useRemoteThreadLiveConnection.ts @@ -10,6 +10,8 @@ import type { WorkspaceInfo } from "@/types"; export type RemoteThreadConnectionState = "live" | "polling" | "disconnected"; +const SELF_DETACH_IGNORE_WINDOW_MS = 10_000; + type ReconnectOptions = { runResume?: boolean; }; @@ -63,6 +65,13 @@ function isDocumentVisible() { return typeof document === "undefined" ? true : document.visibilityState === "visible"; } +function isWindowFocused() { + if (typeof document === "undefined" || typeof document.hasFocus !== "function") { + return true; + } + return document.hasFocus(); +} + export function useRemoteThreadLiveConnection({ backendMode, activeWorkspace, @@ -71,6 +80,8 @@ export function useRemoteThreadLiveConnection({ refreshThread, reconnectWorkspace, }: UseRemoteThreadLiveConnectionOptions) { + const activeWorkspaceId = activeWorkspace?.id ?? null; + const activeWorkspaceConnected = activeWorkspace?.connected ?? false; const [connectionState, setConnectionState] = useState(() => { if (backendMode !== "remote") { @@ -90,8 +101,14 @@ export function useRemoteThreadLiveConnection({ const reconnectWorkspaceRef = useRef(reconnectWorkspace); const connectionStateRef = useRef(connectionState); const activeSubscriptionKeyRef = useRef(null); + const desiredSubscriptionKeyRef = useRef(null); + const ignoreDetachedEventsUntilRef = useRef>(new Map()); + const inFlightReconnectRef = useRef<{ + key: string; + sequence: number; + promise: Promise; + } | null>(null); const reconnectSequenceRef = useRef(0); - const lastThreadEventAtRef = useRef(0); useEffect(() => { backendModeRef.current = backendMode; @@ -163,53 +180,98 @@ export function useRemoteThreadLiveConnection({ return false; } - const sequence = reconnectSequenceRef.current + 1; - reconnectSequenceRef.current = sequence; - setState(activeWorkspaceRef.current.connected ? "polling" : "disconnected"); - - try { - if ( - !activeWorkspaceRef.current.connected && - reconnectWorkspaceRef.current && - activeWorkspaceRef.current.id === workspaceId - ) { - await Promise.resolve(reconnectWorkspaceRef.current(activeWorkspaceRef.current)); - } - if (sequence !== reconnectSequenceRef.current) { - return false; + const targetKey = keyForThread(workspaceId, threadId); + desiredSubscriptionKeyRef.current = targetKey; + const inFlightReconnect = inFlightReconnectRef.current; + if (inFlightReconnect?.key === targetKey) { + if (inFlightReconnect.sequence === reconnectSequenceRef.current) { + return inFlightReconnect.promise; } + // A newer sequence (blur/focus/key change) has invalidated this attempt. + inFlightReconnectRef.current = null; + } - if (options?.runResume !== false) { - await Promise.resolve(refreshThreadRef.current(workspaceId, threadId)); - } - if (sequence !== reconnectSequenceRef.current) { - return false; - } + const reconnectPromise = (async (): Promise => { + const sequence = reconnectSequenceRef.current + 1; + reconnectSequenceRef.current = sequence; + const workspaceAtStart = activeWorkspaceRef.current; + setState(workspaceAtStart?.connected ? "polling" : "disconnected"); + + try { + desiredSubscriptionKeyRef.current = targetKey; + const workspaceEntry = activeWorkspaceRef.current; + if ( + workspaceEntry && + !workspaceEntry.connected && + reconnectWorkspaceRef.current && + workspaceEntry.id === workspaceId + ) { + await Promise.resolve(reconnectWorkspaceRef.current(workspaceEntry)); + } + if (sequence !== reconnectSequenceRef.current) { + return false; + } + + if (options?.runResume !== false) { + await Promise.resolve(refreshThreadRef.current(workspaceId, threadId)); + } + if (sequence !== reconnectSequenceRef.current) { + return false; + } + + if (activeSubscriptionKeyRef.current === targetKey) { + ignoreDetachedEventsUntilRef.current.set( + targetKey, + Date.now() + SELF_DETACH_IGNORE_WINDOW_MS, + ); + await threadLiveUnsubscribe(workspaceId, threadId).catch(() => { + // Best-effort dedupe: ignore unsubscribe failures before reattach. + }); + activeSubscriptionKeyRef.current = null; + } + await threadLiveSubscribe(workspaceId, threadId); + if (sequence !== reconnectSequenceRef.current) { + if (desiredSubscriptionKeyRef.current !== targetKey) { + await threadLiveUnsubscribe(workspaceId, threadId).catch(() => { + // Best-effort cleanup for stale reconnect attempts. + }); + } + return false; + } - await threadLiveSubscribe(workspaceId, threadId); - if (sequence !== reconnectSequenceRef.current) { + activeSubscriptionKeyRef.current = targetKey; + setState("polling"); + return true; + } catch { + if (sequence === reconnectSequenceRef.current) { + reconcileDisconnectedState(); + } return false; } - - activeSubscriptionKeyRef.current = keyForThread(workspaceId, threadId); - setState("polling"); - return true; - } catch { - if (sequence === reconnectSequenceRef.current) { - reconcileDisconnectedState(); + })(); + + const reconnectSequence = reconnectSequenceRef.current; + inFlightReconnectRef.current = { + key: targetKey, + sequence: reconnectSequence, + promise: reconnectPromise, + }; + reconnectPromise.finally(() => { + if (inFlightReconnectRef.current?.promise === reconnectPromise) { + inFlightReconnectRef.current = null; } - return false; - } + }); + return reconnectPromise; }, [reconcileDisconnectedState, setState], ); useEffect(() => { - const workspace = activeWorkspace; const nextKey = - backendMode === "remote" && workspace?.id && activeThreadId - ? keyForThread(workspace.id, activeThreadId) + backendMode === "remote" && activeWorkspaceId && activeThreadId + ? keyForThread(activeWorkspaceId, activeThreadId) : null; + desiredSubscriptionKeyRef.current = nextKey; const previousKey = activeSubscriptionKeyRef.current; if (previousKey && previousKey !== nextKey) { @@ -230,10 +292,18 @@ export function useRemoteThreadLiveConnection({ reconcileDisconnectedState(); return; } + if ( + activeSubscriptionKeyRef.current === nextKey && + connectionStateRef.current !== "disconnected" && + activeWorkspaceConnected + ) { + return; + } void reconnectLive(parsed.workspaceId, parsed.threadId, { runResume: true }); }, [ activeThreadId, - activeWorkspace, + activeWorkspaceConnected, + activeWorkspaceId, backendMode, reconcileDisconnectedState, reconnectLive, @@ -274,8 +344,23 @@ export function useRemoteThreadLiveConnection({ if (method === "thread/live_detached") { const threadId = extractThreadId(method, params); if (threadId === selectedThreadId) { + const threadKey = keyForThread(activeWorkspaceId, threadId); + const ignoreDetachedUntil = + ignoreDetachedEventsUntilRef.current.get(threadKey) ?? 0; + if (ignoreDetachedUntil > 0 && ignoreDetachedUntil >= Date.now()) { + ignoreDetachedEventsUntilRef.current.delete(threadKey); + return; + } + if (ignoreDetachedUntil > 0) { + ignoreDetachedEventsUntilRef.current.delete(threadKey); + } activeSubscriptionKeyRef.current = null; reconcileDisconnectedState(); + if (isDocumentVisible() && isWindowFocused()) { + void reconnectLive(activeWorkspaceId, selectedThreadId, { + runResume: true, + }); + } } return; } @@ -283,7 +368,6 @@ export function useRemoteThreadLiveConnection({ if (method === "thread/live_heartbeat") { const threadId = extractThreadId(method, params); if (threadId === selectedThreadId) { - lastThreadEventAtRef.current = Date.now(); setState("live"); } return; @@ -296,7 +380,6 @@ export function useRemoteThreadLiveConnection({ if (threadId !== selectedThreadId) { return; } - lastThreadEventAtRef.current = Date.now(); setState("live"); }); @@ -327,6 +410,8 @@ export function useRemoteThreadLiveConnection({ }; const handleBlur = () => { + reconnectSequenceRef.current += 1; + desiredSubscriptionKeyRef.current = null; const currentKey = activeSubscriptionKeyRef.current; if (!currentKey) { return; @@ -389,6 +474,8 @@ export function useRemoteThreadLiveConnection({ window.removeEventListener("focus", handleFocus); window.removeEventListener("blur", handleBlur); document.removeEventListener("visibilitychange", handleVisibilityChange); + desiredSubscriptionKeyRef.current = null; + ignoreDetachedEventsUntilRef.current.clear(); const currentKey = activeSubscriptionKeyRef.current; if (currentKey) { activeSubscriptionKeyRef.current = null; diff --git a/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.test.tsx b/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.test.tsx new file mode 100644 index 00000000..65df634e --- /dev/null +++ b/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkspaceRefreshOnFocus } from "./useWorkspaceRefreshOnFocus"; + +describe("useWorkspaceRefreshOnFocus", () => { + let visibilityState: DocumentVisibilityState; + + beforeEach(() => { + vi.useFakeTimers(); + visibilityState = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("refreshes workspaces and connected threads on focus", async () => { + const refreshWorkspaces = vi.fn().mockResolvedValue([ + { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + ]); + const listThreadsForWorkspace = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useWorkspaceRefreshOnFocus({ + workspaces: [], + refreshWorkspaces, + listThreadsForWorkspace, + }), + ); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + vi.advanceTimersByTime(500); + await Promise.resolve(); + }); + + expect(refreshWorkspaces).toHaveBeenCalledTimes(1); + expect(listThreadsForWorkspace).toHaveBeenCalledTimes(1); + expect(listThreadsForWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ id: "ws-1" }), + { preserveState: true }, + ); + }); + + it("polls automatically in remote mode", async () => { + const refreshWorkspaces = vi.fn().mockResolvedValue([ + { + id: "ws-1", + name: "Workspace", + path: "/tmp/ws-1", + connected: true, + settings: { sidebarCollapsed: false }, + }, + ]); + const listThreadsForWorkspace = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useWorkspaceRefreshOnFocus({ + workspaces: [], + refreshWorkspaces, + listThreadsForWorkspace, + backendMode: "remote", + pollIntervalMs: 2000, + }), + ); + + await act(async () => { + vi.advanceTimersByTime(1999); + await Promise.resolve(); + }); + expect(refreshWorkspaces).toHaveBeenCalledTimes(0); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + expect(refreshWorkspaces).toHaveBeenCalledTimes(1); + expect(listThreadsForWorkspace).toHaveBeenCalledTimes(1); + }); + + it("does not poll when backend mode is local", async () => { + const refreshWorkspaces = vi.fn().mockResolvedValue([]); + const listThreadsForWorkspace = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useWorkspaceRefreshOnFocus({ + workspaces: [], + refreshWorkspaces, + listThreadsForWorkspace, + backendMode: "local", + pollIntervalMs: 1000, + }), + ); + + await act(async () => { + vi.advanceTimersByTime(4000); + await Promise.resolve(); + }); + + expect(refreshWorkspaces).toHaveBeenCalledTimes(0); + expect(listThreadsForWorkspace).toHaveBeenCalledTimes(0); + }); + + it("starts polling when backend mode changes from local to remote", async () => { + const refreshWorkspaces = vi.fn().mockResolvedValue([]); + const listThreadsForWorkspace = vi.fn().mockResolvedValue(undefined); + + const { rerender } = renderHook( + (props: { backendMode: string }) => + useWorkspaceRefreshOnFocus({ + workspaces: [], + refreshWorkspaces, + listThreadsForWorkspace, + backendMode: props.backendMode, + pollIntervalMs: 1000, + }), + { + initialProps: { backendMode: "local" }, + }, + ); + + await act(async () => { + vi.advanceTimersByTime(1500); + await Promise.resolve(); + }); + expect(refreshWorkspaces).toHaveBeenCalledTimes(0); + + rerender({ backendMode: "remote" }); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + expect(refreshWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("stops polling while hidden and resumes when visible again", async () => { + const refreshWorkspaces = vi.fn().mockResolvedValue([]); + const listThreadsForWorkspace = vi.fn().mockResolvedValue(undefined); + + renderHook(() => + useWorkspaceRefreshOnFocus({ + workspaces: [], + refreshWorkspaces, + listThreadsForWorkspace, + backendMode: "remote", + pollIntervalMs: 1000, + }), + ); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + expect(refreshWorkspaces).toHaveBeenCalledTimes(1); + + await act(async () => { + visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(3000); + await Promise.resolve(); + }); + expect(refreshWorkspaces).toHaveBeenCalledTimes(1); + + await act(async () => { + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + expect(refreshWorkspaces).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts b/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts index b7fc03c1..160469ec 100644 --- a/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts +++ b/src/features/workspaces/hooks/useWorkspaceRefreshOnFocus.ts @@ -1,6 +1,8 @@ import { useEffect, useRef } from "react"; import type { WorkspaceInfo } from "../../../types"; +export const REMOTE_WORKSPACE_REFRESH_INTERVAL_MS = 15_000; + type WorkspaceRefreshOptions = { workspaces: WorkspaceInfo[]; refreshWorkspaces: () => Promise; @@ -8,61 +10,116 @@ type WorkspaceRefreshOptions = { workspace: WorkspaceInfo, options?: { preserveState?: boolean }, ) => Promise; + backendMode?: string; + pollIntervalMs?: number; }; export function useWorkspaceRefreshOnFocus({ workspaces, refreshWorkspaces, listThreadsForWorkspace, + backendMode = "local", + pollIntervalMs = REMOTE_WORKSPACE_REFRESH_INTERVAL_MS, }: WorkspaceRefreshOptions) { - const optionsRef = useRef({ workspaces, refreshWorkspaces, listThreadsForWorkspace }); + const optionsRef = useRef({ + workspaces, + refreshWorkspaces, + listThreadsForWorkspace, + backendMode, + pollIntervalMs, + }); useEffect(() => { - optionsRef.current = { workspaces, refreshWorkspaces, listThreadsForWorkspace }; + optionsRef.current = { + workspaces, + refreshWorkspaces, + listThreadsForWorkspace, + backendMode, + pollIntervalMs, + }; }); useEffect(() => { let debounceTimer: ReturnType | null = null; + let pollTimer: ReturnType | null = null; + let refreshInFlight = false; - const handleFocus = () => { + const runRefreshCycle = () => { + if (refreshInFlight) { + return; + } + refreshInFlight = true; + const { + workspaces: ws, + refreshWorkspaces: refresh, + listThreadsForWorkspace: listThreads, + } = optionsRef.current; + void (async () => { + let latestWorkspaces = ws; + try { + const entries = await refresh(); + if (entries) { + latestWorkspaces = entries; + } + } catch { + // Silent: refresh errors show in debug panel. + } + const connected = latestWorkspaces.filter((entry) => entry.connected); + await Promise.allSettled( + connected.map((workspace) => listThreads(workspace, { preserveState: true })), + ); + })().finally(() => { + refreshInFlight = false; + }); + }; + + const updatePolling = () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + const { backendMode: currentBackendMode, pollIntervalMs: intervalMs } = + optionsRef.current; + if (currentBackendMode !== "remote" || document.visibilityState !== "visible") { + return; + } + pollTimer = setInterval(() => { + runRefreshCycle(); + }, intervalMs); + }; + + const scheduleRefresh = () => { if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { - const { workspaces: ws, refreshWorkspaces: refresh, listThreadsForWorkspace: listThreads } = optionsRef.current; - void (async () => { - let latestWorkspaces = ws; - try { - const entries = await refresh(); - if (entries) { - latestWorkspaces = entries; - } - } catch { - // Silent: refresh errors show in debug panel. - } - const connected = latestWorkspaces.filter((entry) => entry.connected); - await Promise.allSettled( - connected.map((workspace) => - listThreads(workspace, { preserveState: true }), - ), - ); - })(); + runRefreshCycle(); }, 500); }; + const handleFocus = () => { + scheduleRefresh(); + updatePolling(); + }; + const handleVisibilityChange = () => { if (document.visibilityState === "visible") { - handleFocus(); + scheduleRefresh(); } + updatePolling(); }; window.addEventListener("focus", handleFocus); document.addEventListener("visibilitychange", handleVisibilityChange); + updatePolling(); return () => { window.removeEventListener("focus", handleFocus); document.removeEventListener("visibilitychange", handleVisibilityChange); if (debounceTimer) { clearTimeout(debounceTimer); } + if (pollTimer) { + clearInterval(pollTimer); + } }; - }, []); + }, [backendMode, pollIntervalMs]); } diff --git a/src/features/workspaces/hooks/useWorkspaces.test.tsx b/src/features/workspaces/hooks/useWorkspaces.test.tsx index c5864ac4..12dc9e92 100644 --- a/src/features/workspaces/hooks/useWorkspaces.test.tsx +++ b/src/features/workspaces/hooks/useWorkspaces.test.tsx @@ -14,6 +14,7 @@ import { renameWorktreeUpstream, updateWorkspaceSettings, } from "../../../services/tauri"; +import { isMobilePlatform } from "../../../utils/platformPaths"; import { useWorkspaces } from "./useWorkspaces"; vi.mock("@tauri-apps/plugin-dialog", () => ({ @@ -38,8 +39,13 @@ vi.mock("../../../services/tauri", () => ({ updateWorkspaceSettings: vi.fn(), })); +vi.mock("../../../utils/platformPaths", () => ({ + isMobilePlatform: vi.fn(() => false), +})); + beforeEach(() => { vi.clearAllMocks(); + vi.mocked(isMobilePlatform).mockReturnValue(false); }); const worktree: WorkspaceInfo = { @@ -361,6 +367,45 @@ describe("useWorkspaces.addWorkspace (bulk)", () => { expect.objectContaining({ title: "Some workspaces were skipped", kind: "warning" }), ); }); + + it("uses manual server paths on mobile remote mode", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const pickWorkspacePathsMock = vi.mocked(pickWorkspacePaths); + const isWorkspacePathDirMock = vi.mocked(isWorkspacePathDir); + const addWorkspaceMock = vi.mocked(addWorkspace); + const promptSpy = vi + .spyOn(window, "prompt") + .mockReturnValue("/srv/repo-a\n/srv/repo-b"); + vi.mocked(isMobilePlatform).mockReturnValue(true); + + listWorkspacesMock.mockResolvedValue([]); + isWorkspacePathDirMock.mockResolvedValue(true); + addWorkspaceMock + .mockResolvedValueOnce({ ...workspaceOne, id: "added-1", path: "/srv/repo-a" }) + .mockResolvedValueOnce({ ...workspaceTwo, id: "added-2", path: "/srv/repo-b" }); + + const { result } = renderHook(() => + useWorkspaces({ + appSettings: { backendMode: "remote" } as never, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + await result.current.addWorkspace(); + }); + + expect(promptSpy).toHaveBeenCalledTimes(1); + expect(pickWorkspacePathsMock).not.toHaveBeenCalled(); + expect(addWorkspaceMock).toHaveBeenCalledTimes(2); + expect(addWorkspaceMock).toHaveBeenNthCalledWith(1, "/srv/repo-a", null); + expect(addWorkspaceMock).toHaveBeenNthCalledWith(2, "/srv/repo-b", null); + + promptSpy.mockRestore(); + }); }); diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index 10778d2c..d249bec2 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -8,6 +8,7 @@ import type { WorkspaceSettings, } from "../../../types"; import { ask, message } from "@tauri-apps/plugin-dialog"; +import { isMobilePlatform } from "../../../utils/platformPaths"; import { addClone as addCloneService, addWorkspace as addWorkspaceService, @@ -79,6 +80,26 @@ function normalizeWorkspacePathKey(value: string) { return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); } +function parseWorkspacePathInput(value: string) { + return value + .split(/\r?\n|,|;/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function promptWorkspacePathsForMobileRemote(): string[] { + if (typeof window === "undefined" || typeof window.prompt !== "function") { + return []; + } + const input = window.prompt( + "Enter one or more project paths on the connected server.\nUse one path per line (or comma-separated).", + ); + if (!input) { + return []; + } + return parseWorkspacePathInput(input); +} + export function useWorkspaces(options: UseWorkspacesOptions = {}) { const [workspaces, setWorkspaces] = useState([]); const [activeWorkspaceId, setActiveWorkspaceId] = useState(null); @@ -433,12 +454,20 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { ); const addWorkspace = useCallback(async () => { + if (isMobilePlatform() && appSettings?.backendMode === "remote") { + const manualPaths = promptWorkspacePathsForMobileRemote(); + if (manualPaths.length === 0) { + return null; + } + return addWorkspacesFromPaths(manualPaths); + } + const selection = await pickWorkspacePaths(); if (selection.length === 0) { return null; } return addWorkspacesFromPaths(selection); - }, [addWorkspacesFromPaths]); + }, [addWorkspacesFromPaths, appSettings?.backendMode]); const filterWorkspacePaths = useCallback(async (paths: string[]) => { const trimmed = paths.map((path) => path.trim()).filter(Boolean);