From 1f3374265d1f98e937ba91804f7e30cd5dd289ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 5 Oct 2026 19:47:21 -0700 Subject: [PATCH 1/3] fix(mobile): refresh Git status after reconnect The selection refresh only ran when the thread or cwd changed, so after a long background the reconnected app kept showing the server's cached Git status. Key the quiet refresh on the environment being connected as well, so each reconnect seeds one fresh status. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../use-selected-thread-git-actions.test.tsx | 193 ++++++++++++++++++ .../state/use-selected-thread-git-actions.ts | 19 +- 2 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/state/use-selected-thread-git-actions.test.tsx diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx new file mode 100644 index 000000000000..b8211276ce64 --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +import { act, useEffect, useReducer } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + selection: { + selectedThread: { environmentId: "environment-1", id: "thread-1" } as unknown, + selectedThreadProject: { workspaceRoot: "/repo" } as unknown, + selectedEnvironmentRuntime: { connectionState: "connected" } as unknown, + }, + worktree: { selectedThreadCwd: "/repo" as string | null, selectedThreadWorktreePath: null }, + refreshStatusCalls: [] as Array, + renderCount: 0, + // Mirrors refreshStatus invalidating refs: settled refreshes rerender consumers. + onRefreshSettled: () => {}, +})); + +vi.mock("./use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === "refreshStatus" + ? async (input: unknown) => { + harness.refreshStatusCalls.push(input); + harness.onRefreshSettled(); + return AsyncResult.success({}); + } + : async () => AsyncResult.success({}), +})); +vi.mock("./vcs", () => ({ + vcsEnvironment: { refreshStatus: "refreshStatus" }, + vcsActionManager: { runStackedAction: () => "runStackedAction", track: vi.fn() }, +})); +vi.mock("./threads", () => ({ threadEnvironment: { updateMetadata: "updateMetadata" } })); +vi.mock("./queries", () => ({ useBranches: () => ({ data: null, refresh: () => {} }) })); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); +vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: () => {} })); +// Runs on every render: a refresh loop fails fast here instead of exhausting memory. +vi.mock("./use-thread-selection", () => ({ + useThreadSelection: () => { + harness.renderCount += 1; + if (harness.renderCount > 50) { + throw new Error("Consumer rerendered more than 50 times"); + } + return harness.selection; + }, +})); +vi.mock("./use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => harness.worktree, +})); + +import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; + +let root: Root | null = null; +let rerender: () => void = () => {}; + +// Stands in for a Git sheet: every settled refresh causes a rerender, as the +// refs invalidation does on device. +function Consumer() { + const [, bump] = useReducer((count: number) => count + 1, 0); + useEffect(() => { + rerender = bump; + }); + useSelectedThreadGitActions(); + return null; +} + +async function flush() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + harness.selection = { + selectedThread: { environmentId: "environment-1", id: "thread-1" }, + selectedThreadProject: { workspaceRoot: "/repo" }, + selectedEnvironmentRuntime: { connectionState: "connected" }, + }; + harness.worktree = { selectedThreadCwd: "/repo", selectedThreadWorktreePath: null }; + harness.refreshStatusCalls = []; + harness.renderCount = 0; + harness.onRefreshSettled = () => rerender(); +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; +}); + +describe("useSelectedThreadGitActions", () => { + it("refreshes once on mount and does not loop when refreshes rerender the consumer", async () => { + root = createRoot(document.createElement("div")); + act(() => root!.render()); + await flush(); + + // Shell updates replace the thread object without changing the selection. + harness.selection = { + selectedThread: { environmentId: "environment-1", id: "thread-1" }, + selectedThreadProject: { workspaceRoot: "/repo" }, + selectedEnvironmentRuntime: { connectionState: "connected" }, + }; + act(() => rerender()); + await flush(); + + expect(harness.refreshStatusCalls).toEqual([ + { environmentId: "environment-1", input: { cwd: "/repo" } }, + ]); + }); + + it("refreshes again when the selected thread or cwd changes", async () => { + root = createRoot(document.createElement("div")); + act(() => root!.render()); + await flush(); + + harness.selection = { + selectedThread: { environmentId: "environment-1", id: "thread-2" }, + selectedThreadProject: { workspaceRoot: "/repo" }, + selectedEnvironmentRuntime: { connectionState: "connected" }, + }; + act(() => rerender()); + await flush(); + + harness.worktree = { selectedThreadCwd: "/repo/worktree", selectedThreadWorktreePath: null }; + act(() => rerender()); + await flush(); + + expect(harness.refreshStatusCalls).toEqual([ + { environmentId: "environment-1", input: { cwd: "/repo" } }, + { environmentId: "environment-1", input: { cwd: "/repo" } }, + { environmentId: "environment-1", input: { cwd: "/repo/worktree" } }, + ]); + }); + + it("refreshes once after the environment reconnects and not while it is away", async () => { + root = createRoot(document.createElement("div")); + act(() => root!.render()); + await flush(); + + for (const connectionState of ["reconnecting", "connecting", "offline"]) { + harness.selection = { + ...harness.selection, + selectedEnvironmentRuntime: { connectionState }, + }; + act(() => rerender()); + await flush(); + } + expect(harness.refreshStatusCalls).toHaveLength(1); + + // The server's cached status can be stale after a long background, so the + // new session refreshes it once. + harness.selection = { + ...harness.selection, + selectedEnvironmentRuntime: { connectionState: "connected" }, + }; + act(() => rerender()); + await flush(); + act(() => rerender()); + await flush(); + + expect(harness.refreshStatusCalls).toEqual([ + { environmentId: "environment-1", input: { cwd: "/repo" } }, + { environmentId: "environment-1", input: { cwd: "/repo" } }, + ]); + }); + + it("waits for the environment to connect before the first refresh", async () => { + harness.selection = { + ...harness.selection, + selectedEnvironmentRuntime: { connectionState: "connecting" }, + }; + root = createRoot(document.createElement("div")); + act(() => root!.render()); + await flush(); + expect(harness.refreshStatusCalls).toEqual([]); + + harness.selection = { + ...harness.selection, + selectedEnvironmentRuntime: { connectionState: "connected" }, + }; + act(() => rerender()); + await flush(); + + expect(harness.refreshStatusCalls).toEqual([ + { environmentId: "environment-1", input: { cwd: "/repo" } }, + ]); + }); +}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 54e8bcd439ea..2b9a18620c29 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -41,7 +41,8 @@ export function useSelectedThreadGitActions() { const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); - const { selectedThread, selectedThreadProject } = useThreadSelection(); + const { selectedThread, selectedThreadProject, selectedEnvironmentRuntime } = + useThreadSelection(); const canWriteSourceControl = useEnvironmentScope( selectedThread?.environmentId ?? null, AuthSourceControlWriteScope, @@ -142,10 +143,13 @@ export function useSelectedThreadGitActions() { // Shell updates replace the selected thread object many times per second while a // turn streams, so key the refresh on primitives. The server publishes status after - // each turn finishes; this only seeds status when the selection or cwd changes. + // each turn finishes; this only seeds status when the selection or cwd changes, and + // again on reconnect because the server's cached status can miss changes made while + // the app was away. const selectedEnvironmentId = selectedThread?.environmentId ?? null; const selectedThreadId = selectedThread?.id ?? null; const hasSelectedThreadProject = selectedThreadProject !== null; + const isEnvironmentConnected = selectedEnvironmentRuntime?.connectionState === "connected"; const refreshOnSelection = useEffectEvent(() => { void refreshSelectedThreadGitStatus({ quiet: true }); }); @@ -154,12 +158,19 @@ export function useSelectedThreadGitActions() { selectedEnvironmentId === null || selectedThreadId === null || !hasSelectedThreadProject || - selectedThreadCwd === null + selectedThreadCwd === null || + !isEnvironmentConnected ) { return; } refreshOnSelection(); - }, [selectedEnvironmentId, selectedThreadId, hasSelectedThreadProject, selectedThreadCwd]); + }, [ + selectedEnvironmentId, + selectedThreadId, + hasSelectedThreadProject, + selectedThreadCwd, + isEnvironmentConnected, + ]); const runSelectedThreadGitMutation = useCallback( async ( From 207af1b3a9eb57f5918a1d5cc716303c584598ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 19:28:56 -0700 Subject: [PATCH 2/3] test(mobile): import reactivity from effect/reactivity Effect 4.0.1 on main moved the reactivity modules out of unstable. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/state/use-selected-thread-git-actions.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx index b8211276ce64..e413a9b9ac2f 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, useEffect, useReducer } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const harness = vi.hoisted(() => ({ From 600d35bae97ab61c95769ac4eeb7da4b8474ede6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:24:44 -0700 Subject: [PATCH 3/3] test(mobile): mock environment scopes in the Git actions hook test Main's source-control write permissions made the hook read session scopes, which pulls Expo into the test environment. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../mobile/src/state/use-selected-thread-git-actions.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx index e413a9b9ac2f..0c9739f16e10 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx @@ -35,6 +35,10 @@ vi.mock("./threads", () => ({ threadEnvironment: { updateMetadata: "updateMetada vi.mock("./queries", () => ({ useBranches: () => ({ data: null, refresh: () => {} }) })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./session", () => ({ + readEnvironmentScope: () => true, + useEnvironmentScope: () => true, +})); vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: () => {} })); // Runs on every render: a refresh loop fails fast here instead of exhausting memory.