authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 22:48:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 22:48:57-07:00
log4fec120b75bb397bda3c09b7317f79c1d26811c0
tree9a6d3d0dc6fec5ad18231f15466a23073acc3d24
parent5c2798d47eb41d5d659b8d2189781fcb06536a5f
signature Signed by PGP key B5690EEEBB952194

fix(mobile): refresh Git status after reconnect (#16329)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 212 insertions(+), 4 deletions(-)

apps/mobile/src/state/use-selected-thread-git-actions.test.tsx created+197
......@@ -0,0 +1,197 @@
1// @vitest-environment jsdom
2import { act, useEffect, useReducer } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { AsyncResult } from "effect/reactivity";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const harness = vi.hoisted(() => ({
8 selection: {
9 selectedThread: { environmentId: "environment-1", id: "thread-1" } as unknown,
10 selectedThreadProject: { workspaceRoot: "/repo" } as unknown,
11 selectedEnvironmentRuntime: { connectionState: "connected" } as unknown,
12 },
13 worktree: { selectedThreadCwd: "/repo" as string | null, selectedThreadWorktreePath: null },
14 refreshStatusCalls: [] as Array<unknown>,
15 renderCount: 0,
16 // Mirrors refreshStatus invalidating refs: settled refreshes rerender consumers.
17 onRefreshSettled: () => {},
18}));
19
20vi.mock("./use-atom-command", () => ({
21 useAtomCommand: (command: unknown) =>
22 command === "refreshStatus"
23 ? async (input: unknown) => {
24 harness.refreshStatusCalls.push(input);
25 harness.onRefreshSettled();
26 return AsyncResult.success({});
27 }
28 : async () => AsyncResult.success({}),
29}));
30vi.mock("./vcs", () => ({
31 vcsEnvironment: { refreshStatus: "refreshStatus" },
32 vcsActionManager: { runStackedAction: () => "runStackedAction", track: vi.fn() },
33}));
34vi.mock("./threads", () => ({ threadEnvironment: { updateMetadata: "updateMetadata" } }));
35vi.mock("./queries", () => ({ useBranches: () => ({ data: null, refresh: () => {} }) }));
36vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" }));
37vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
38vi.mock("./session", () => ({
39 readEnvironmentScope: () => true,
40 useEnvironmentScope: () => true,
41}));
42vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} }));
43vi.mock("./use-vcs-action-state", () => ({ showGitActionResult: () => {} }));
44// Runs on every render: a refresh loop fails fast here instead of exhausting memory.
45vi.mock("./use-thread-selection", () => ({
46 useThreadSelection: () => {
47 harness.renderCount += 1;
48 if (harness.renderCount > 50) {
49 throw new Error("Consumer rerendered more than 50 times");
50 }
51 return harness.selection;
52 },
53}));
54vi.mock("./use-selected-thread-worktree", () => ({
55 useSelectedThreadWorktree: () => harness.worktree,
56}));
57
58import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions";
59
60let root: Root | null = null;
61let rerender: () => void = () => {};
62
63// Stands in for a Git sheet: every settled refresh causes a rerender, as the
64// refs invalidation does on device.
65function Consumer() {
66 const [, bump] = useReducer((count: number) => count + 1, 0);
67 useEffect(() => {
68 rerender = bump;
69 });
70 useSelectedThreadGitActions();
71 return null;
72}
73
74async function flush() {
75 for (let index = 0; index < 5; index += 1) {
76 await act(async () => {
77 await Promise.resolve();
78 });
79 }
80}
81
82beforeEach(() => {
83 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
84 harness.selection = {
85 selectedThread: { environmentId: "environment-1", id: "thread-1" },
86 selectedThreadProject: { workspaceRoot: "/repo" },
87 selectedEnvironmentRuntime: { connectionState: "connected" },
88 };
89 harness.worktree = { selectedThreadCwd: "/repo", selectedThreadWorktreePath: null };
90 harness.refreshStatusCalls = [];
91 harness.renderCount = 0;
92 harness.onRefreshSettled = () => rerender();
93});
94
95afterEach(() => {
96 act(() => root?.unmount());
97 root = null;
98});
99
100describe("useSelectedThreadGitActions", () => {
101 it("refreshes once on mount and does not loop when refreshes rerender the consumer", async () => {
102 root = createRoot(document.createElement("div"));
103 act(() => root!.render(<Consumer />));
104 await flush();
105
106 // Shell updates replace the thread object without changing the selection.
107 harness.selection = {
108 selectedThread: { environmentId: "environment-1", id: "thread-1" },
109 selectedThreadProject: { workspaceRoot: "/repo" },
110 selectedEnvironmentRuntime: { connectionState: "connected" },
111 };
112 act(() => rerender());
113 await flush();
114
115 expect(harness.refreshStatusCalls).toEqual([
116 { environmentId: "environment-1", input: { cwd: "/repo" } },
117 ]);
118 });
119
120 it("refreshes again when the selected thread or cwd changes", async () => {
121 root = createRoot(document.createElement("div"));
122 act(() => root!.render(<Consumer />));
123 await flush();
124
125 harness.selection = {
126 selectedThread: { environmentId: "environment-1", id: "thread-2" },
127 selectedThreadProject: { workspaceRoot: "/repo" },
128 selectedEnvironmentRuntime: { connectionState: "connected" },
129 };
130 act(() => rerender());
131 await flush();
132
133 harness.worktree = { selectedThreadCwd: "/repo/worktree", selectedThreadWorktreePath: null };
134 act(() => rerender());
135 await flush();
136
137 expect(harness.refreshStatusCalls).toEqual([
138 { environmentId: "environment-1", input: { cwd: "/repo" } },
139 { environmentId: "environment-1", input: { cwd: "/repo" } },
140 { environmentId: "environment-1", input: { cwd: "/repo/worktree" } },
141 ]);
142 });
143
144 it("refreshes once after the environment reconnects and not while it is away", async () => {
145 root = createRoot(document.createElement("div"));
146 act(() => root!.render(<Consumer />));
147 await flush();
148
149 for (const connectionState of ["reconnecting", "connecting", "offline"]) {
150 harness.selection = {
151 ...harness.selection,
152 selectedEnvironmentRuntime: { connectionState },
153 };
154 act(() => rerender());
155 await flush();
156 }
157 expect(harness.refreshStatusCalls).toHaveLength(1);
158
159 // The server's cached status can be stale after a long background, so the
160 // new session refreshes it once.
161 harness.selection = {
162 ...harness.selection,
163 selectedEnvironmentRuntime: { connectionState: "connected" },
164 };
165 act(() => rerender());
166 await flush();
167 act(() => rerender());
168 await flush();
169
170 expect(harness.refreshStatusCalls).toEqual([
171 { environmentId: "environment-1", input: { cwd: "/repo" } },
172 { environmentId: "environment-1", input: { cwd: "/repo" } },
173 ]);
174 });
175
176 it("waits for the environment to connect before the first refresh", async () => {
177 harness.selection = {
178 ...harness.selection,
179 selectedEnvironmentRuntime: { connectionState: "connecting" },
180 };
181 root = createRoot(document.createElement("div"));
182 act(() => root!.render(<Consumer />));
183 await flush();
184 expect(harness.refreshStatusCalls).toEqual([]);
185
186 harness.selection = {
187 ...harness.selection,
188 selectedEnvironmentRuntime: { connectionState: "connected" },
189 };
190 act(() => rerender());
191 await flush();
192
193 expect(harness.refreshStatusCalls).toEqual([
194 { environmentId: "environment-1", input: { cwd: "/repo" } },
195 ]);
196 });
197});
apps/mobile/src/state/use-selected-thread-git-actions.ts+15-4
......@@ -41,7 +41,8 @@ export function useSelectedThreadGitActions() {
4141 const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false });
4242 const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });
4343 const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
44 const { selectedThread, selectedThreadProject } = useThreadSelection();
44 const { selectedThread, selectedThreadProject, selectedEnvironmentRuntime } =
45 useThreadSelection();
4546 const canWriteSourceControl = useEnvironmentScope(
4647 selectedThread?.environmentId ?? null,
4748 AuthSourceControlWriteScope,
......@@ -142,10 +143,13 @@ export function useSelectedThreadGitActions() {
142143
143144 // Shell updates replace the selected thread object many times per second while a
144145 // turn streams, so key the refresh on primitives. The server publishes status after
145 // each turn finishes; this only seeds status when the selection or cwd changes.
146 // each turn finishes; this only seeds status when the selection or cwd changes, and
147 // again on reconnect because the server's cached status can miss changes made while
148 // the app was away.
146149 const selectedEnvironmentId = selectedThread?.environmentId ?? null;
147150 const selectedThreadId = selectedThread?.id ?? null;
148151 const hasSelectedThreadProject = selectedThreadProject !== null;
152 const isEnvironmentConnected = selectedEnvironmentRuntime?.connectionState === "connected";
149153 const refreshOnSelection = useEffectEvent(() => {
150154 void refreshSelectedThreadGitStatus({ quiet: true });
151155 });
......@@ -154,12 +158,19 @@ export function useSelectedThreadGitActions() {
154158 selectedEnvironmentId === null ||
155159 selectedThreadId === null ||
156160 !hasSelectedThreadProject ||
157 selectedThreadCwd === null
161 selectedThreadCwd === null ||
162 !isEnvironmentConnected
158163 ) {
159164 return;
160165 }
161166 refreshOnSelection();
162 }, [selectedEnvironmentId, selectedThreadId, hasSelectedThreadProject, selectedThreadCwd]);
167 }, [
168 selectedEnvironmentId,
169 selectedThreadId,
170 hasSelectedThreadProject,
171 selectedThreadCwd,
172 isEnvironmentConnected,
173 ]);
163174
164175 const runSelectedThreadGitMutation = useCallback(
165176 async <T, E>(