From b66031a6541d18313f95ca956058abb78c897c09 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:36:55 +0000 Subject: [PATCH 1/7] test(mobile): reproduce #30849 worktree setup retry not reaching the card Retrying a failed worktree setup leaves the mobile setup card frozen on the first attempt's failed snapshot because the hook never resubscribes. Co-Authored-By: Claude Opus 5.5 --- .../threads/use-worktree-setup.test.tsx | 74 +++++++++++++++++++ apps/mobile/vite.config.ts | 1 + 2 files changed, 75 insertions(+) create mode 100644 apps/mobile/src/features/threads/use-worktree-setup.test.tsx diff --git a/apps/mobile/src/features/threads/use-worktree-setup.test.tsx b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx new file mode 100644 index 0000000000000..91ad1380cb445 --- /dev/null +++ b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx @@ -0,0 +1,74 @@ +import { EnvironmentId, ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + server: null as WorktreeSetupSnapshot | null, +})); + +vi.mock("../../state/vcs", () => ({ + vcsEnvironment: { worktreeSetup: () => "worktree-setup" }, +})); +// The server only pushes snapshots to an open subscription. +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: (atom: string | null) => ({ + data: atom === "worktree-setup" ? mocks.server : null, + isPending: false, + refresh: () => {}, + }), +})); + +import { useWorktreeSetup } from "./use-worktree-setup"; + +const environmentId = EnvironmentId.make("environment"); +const threadId = ThreadId.make("setup-thread"); +const failed: WorktreeSetupSnapshot = { + threadId, + sequence: 2, + phase: "failed", + startedAt: "2026-10-30T00:00:00.000Z", + endedAt: "2026-10-30T00:00:00.980Z", + branch: "feature", + baseRef: "origin/main", + worktreePath: null, + setupScript: null, + stages: [], + error: "Fetch base branch failed", +}; + +it("shows the retry's progress after a failed worktree setup", async () => { + mocks.server = failed; + const seen: Array = []; + function Probe(props: { preparing: boolean }) { + const { snapshot } = useWorktreeSetup({ + environmentId, + threadId, + preparing: props.preparing, + turnStarted: false, + followUpSent: false, + }); + useLayoutEffect(() => { + seen.push(snapshot); + }); + return null; + } + + const root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await act(async () => root.render()); + expect(seen.at(-1)).toBe(failed); + + const retry: WorktreeSetupSnapshot = { + ...failed, + sequence: 3, + phase: "running", + startedAt: "2026-10-30T00:01:00.000Z", + endedAt: null, + error: null, + }; + mocks.server = retry; + await act(async () => root.render()); + expect(seen.at(-1)).toBe(retry); + act(() => root.unmount()); +}); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6f5c4558b5a27..e9454ab5cfcb9 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -38,6 +38,7 @@ const reactTests = [ "src/state/use-selected-thread-git-state.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", "src/features/threads/use-thread-list-now.test.tsx", + "src/features/threads/use-worktree-setup.test.tsx", "src/features/threads/useThreadListV2.test.tsx", "src/features/home/home-list-options.test.tsx", "src/features/workspace/useSearchScrollRestoration.test.tsx", From 9a79dc485d6d8de8729d3821899ceadaefce0f15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:40:13 +0000 Subject: [PATCH 2/7] fix(mobile): worktree setup card follows a retried preparation Retrying a failed workspace preparation started a new setup on the server, but the card stayed on the first attempt's failed snapshot because the hook only subscribed while it held no snapshot or a running one. The hook now subscribes whenever the run is preparing, and keeps the worktree-path signal as a separate one-shot read so idle worktree threads do not hold a subscription open. The Retry button shows its loading state while the request is in flight. Fixes pingdotgg/t3code#30849 Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 2 +- apps/mobile/src/features/threads/thread-work-log.tsx | 2 +- apps/mobile/src/features/threads/use-worktree-setup.test.tsx | 1 + apps/mobile/src/features/threads/use-worktree-setup.ts | 4 +++- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 94ddab608bd8d..6dbd188db44e6 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -847,9 +847,9 @@ function ThreadRouteContent( preparing: composer.selectedThreadActivityRun?.status === "preparing" || selectedThread?.runtime?.status === "preparing" || - selectedThread?.worktreePath != null || (selectedThreadCreation?.message.creation?.workspaceMode === "worktree" && selectedThreadCreation.outcome == null), + hasWorktree: selectedThread?.worktreePath != null, turnStarted: setupTurnStartedAt !== null, followUpSent: composer.selectedThreadFeed.filter( diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index ae943c816140e..3512d03617a06 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -860,7 +860,7 @@ function WorkspacePreparationRetryButton(props: { label="Retry" tone="secondary" accessibilityLabel="Retry workspace preparation" - disabled={busy} + loading={busy} onPress={() => { setBusy(true); void Haptics.selectionAsync(); diff --git a/apps/mobile/src/features/threads/use-worktree-setup.test.tsx b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx index 91ad1380cb445..6ee1f51826b9a 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.test.tsx +++ b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx @@ -45,6 +45,7 @@ it("shows the retry's progress after a failed worktree setup", async () => { environmentId, threadId, preparing: props.preparing, + hasWorktree: false, turnStarted: false, followUpSent: false, }); diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts index 5972dc7b28180..6b64cf33eaf88 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.ts +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -9,6 +9,8 @@ export function useWorktreeSetup(input: { environmentId: EnvironmentId | null; threadId: ThreadId | null; preparing: boolean; + /** Reads a worktree thread's last snapshot once; only `preparing` reopens after it. */ + hasWorktree: boolean; turnStarted: boolean; followUpSent: boolean; }) { @@ -18,7 +20,7 @@ export function useWorktreeSetup(input: { const query = useEnvironmentQuery( input.environmentId && input.threadId && - (live?.phase === "running" || (!live && input.preparing)) + (input.preparing || live?.phase === "running" || (!live && input.hasWorktree)) ? vcsEnvironment.worktreeSetup({ environmentId: input.environmentId, input: { threadId: input.threadId }, From 229b94105eaea65820bf1385f2b73229d4e9c1f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:52:14 +0000 Subject: [PATCH 3/7] fix(client): a later worktree setup attempt replaces a held failure The server forgets a finished setup after 30 seconds (or on restart), so a later Retry starts again at sequence 0. Clients compared sequence only and kept showing the old failure. Order snapshots by startedAt first, then by sequence, in one shared helper used by the mobile and web resolvers. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/worktree-setup-state.test.ts | 7 +++++++ .../src/features/threads/worktree-setup-state.ts | 5 ++++- apps/web/src/components/ChatView.logic.test.ts | 6 ++++++ apps/web/src/components/ChatView.logic.ts | 8 ++++++-- apps/web/src/components/chat/useWorktreeSetup.ts | 8 ++++++-- packages/client-runtime/src/worktreeSetup.ts | 11 ++++++++++- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/worktree-setup-state.test.ts b/apps/mobile/src/features/threads/worktree-setup-state.test.ts index 3598f0000023e..6b8d3b5abc1e3 100644 --- a/apps/mobile/src/features/threads/worktree-setup-state.test.ts +++ b/apps/mobile/src/features/threads/worktree-setup-state.test.ts @@ -68,6 +68,13 @@ describe("mobile worktree setup handoff", () => { }, ); + it("takes a later retry even after the server restarts its sequence", () => { + const failed = { ...running, phase: "failed" as const, sequence: 2 }; + const retry = { ...running, startedAt: "2026-09-17T00:01:00.000Z", sequence: 0 }; + expect(resolveWorktreeSetupSnapshot(threadId, retry, failed)).toBe(retry); + expect(resolveWorktreeSetupSnapshot(threadId, failed, retry)).toBe(retry); + }); + it("does not carry a previous thread's progress across navigation", () => { const otherId = ThreadId.make("other-thread"); expect(resolveWorktreeSetupSnapshot(otherId, running, running)).toBeNull(); diff --git a/apps/mobile/src/features/threads/worktree-setup-state.ts b/apps/mobile/src/features/threads/worktree-setup-state.ts index 8a82caa48f15c..b83292e1a5afe 100644 --- a/apps/mobile/src/features/threads/worktree-setup-state.ts +++ b/apps/mobile/src/features/threads/worktree-setup-state.ts @@ -1,4 +1,5 @@ import type { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import { compareWorktreeSetupSnapshots } from "@t3tools/client-runtime/worktree-setup"; /** A subscription can close or replay an older value during the setup handoff. */ export function resolveWorktreeSetupSnapshot( @@ -8,5 +9,7 @@ export function resolveWorktreeSetupSnapshot( ): WorktreeSetupSnapshot | null { const current = latest?.threadId === threadId ? latest : null; const previous = held?.threadId === threadId ? held : null; - return current && (!previous || current.sequence >= previous.sequence) ? current : previous; + return current && (!previous || compareWorktreeSetupSnapshots(current, previous) >= 0) + ? current + : previous; } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 81b6992a59d3b..c90fc2a295ecd 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -722,6 +722,12 @@ describe("worktree setup visibility", () => { }); expect(resolve(newest, base)).toEqual({ snapshot: newest, isPreparingWorktree: false }); expect(resolve(base, newest)).toEqual({ snapshot: newest, isPreparingWorktree: false }); + const retry = { + ...base, + startedAt: new Date(Date.parse(now) + 60_000).toISOString(), + sequence: 0, + }; + expect(resolve(retry, newest).snapshot).toBe(retry); const other = { ...base, threadId: ThreadId.make("another-thread") }; expect(resolve(other, other)).toEqual({ snapshot: null, isPreparingWorktree: false }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4e17fdebf7b52..485a16254cb66 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -46,7 +46,10 @@ import { type RunId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { + compareWorktreeSetupSnapshots, + worktreeSetupAgentStarted, +} from "@t3tools/client-runtime/worktree-setup"; import * as DateTime from "effect/DateTime"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { @@ -304,7 +307,8 @@ export function resolveWorktreeSetupProgress(input: { }) { const latest = input.latest?.threadId === input.threadId ? input.latest : null; const held = input.held?.threadId === input.threadId ? input.held : null; - const snapshot = latest && (!held || latest.sequence >= held.sequence) ? latest : held; + const snapshot = + latest && (!held || compareWorktreeSetupSnapshots(latest, held) >= 0) ? latest : held; return { snapshot, isPreparingWorktree: diff --git a/apps/web/src/components/chat/useWorktreeSetup.ts b/apps/web/src/components/chat/useWorktreeSetup.ts index c2a5703235d44..eb322811782a8 100644 --- a/apps/web/src/components/chat/useWorktreeSetup.ts +++ b/apps/web/src/components/chat/useWorktreeSetup.ts @@ -17,7 +17,10 @@ import { useTerminalUiStateStore } from "../../terminalUiStateStore"; import type { Thread } from "../../types"; import { focusTerminal } from "../../lib/terminalFocus"; import { resolveVisibleWorktreeSetup, resolveWorktreeSetupProgress } from "../ChatView.logic"; -import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { + compareWorktreeSetupSnapshots, + worktreeSetupAgentStarted, +} from "@t3tools/client-runtime/worktree-setup"; // Share in-flight preparation retries across ChatView instances. const retryingWorkspacePreparationRunIds = new Set(); @@ -84,7 +87,8 @@ export function useWorktreeSetup(input: { setHeldSnapshot(null); } else if (latestSnapshot) { setHeldSnapshot((current) => - current?.threadId === latestSnapshot.threadId && current.sequence > latestSnapshot.sequence + current?.threadId === latestSnapshot.threadId && + compareWorktreeSetupSnapshots(current, latestSnapshot) > 0 ? current : latestSnapshot, ); diff --git a/packages/client-runtime/src/worktreeSetup.ts b/packages/client-runtime/src/worktreeSetup.ts index 0992f63fbc036..f3a673c5df567 100644 --- a/packages/client-runtime/src/worktreeSetup.ts +++ b/packages/client-runtime/src/worktreeSetup.ts @@ -1,5 +1,13 @@ import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; +/** The server restarts `sequence` for a later attempt, so a later `startedAt` wins first. */ +export function compareWorktreeSetupSnapshots( + a: WorktreeSetupSnapshot, + b: WorktreeSetupSnapshot, +): number { + return Date.parse(a.startedAt) - Date.parse(b.startedAt) || a.sequence - b.sequence; +} + export function resolveVisibleWorktreeSetup(input: { live: WorktreeSetupSnapshot | null; recorded: WorktreeSetupSnapshot | null; @@ -7,7 +15,8 @@ export function resolveVisibleWorktreeSetup(input: { followUpSent: boolean; }): WorktreeSetupSnapshot | null { const snapshot = - input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) + input.live && + (!input.recorded || compareWorktreeSetupSnapshots(input.live, input.recorded) >= 0) ? input.live : input.recorded; if (!snapshot) return null; From 736f0248414bd7c97d2ef7936dfee481cddff39e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 14:02:01 +0000 Subject: [PATCH 4/7] docs(mobile): describe when the worktree setup read stays open Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/use-worktree-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts index 6b64cf33eaf88..b2ba067ec2786 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.ts +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -9,7 +9,7 @@ export function useWorktreeSetup(input: { environmentId: EnvironmentId | null; threadId: ThreadId | null; preparing: boolean; - /** Reads a worktree thread's last snapshot once; only `preparing` reopens after it. */ + /** Reads a worktree thread's snapshot until one is held; after that only `preparing` reopens it. */ hasWorktree: boolean; turnStarted: boolean; followUpSent: boolean; From 8c1b2cc0a0797d5d6a77e0e6c03fa33e08d60a51 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 15:03:16 +0000 Subject: [PATCH 5/7] fix(mobile): keep reading worktree setup until a retry's snapshot arrives A retry that failed before the reopened subscription delivered its first snapshot closed the read and left the previous failure on screen. The hook now remembers the held terminal snapshot when preparing starts and keeps reading until a newer attempt arrives. Co-Authored-By: Claude Opus 5.5 --- .../threads/use-worktree-setup.test.tsx | 44 ++++++++++++++++--- .../features/threads/use-worktree-setup.ts | 24 +++++++++- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/use-worktree-setup.test.tsx b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx index 6ee1f51826b9a..262068b2c067f 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.test.tsx +++ b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx @@ -37,10 +37,8 @@ const failed: WorktreeSetupSnapshot = { error: "Fetch base branch failed", }; -it("shows the retry's progress after a failed worktree setup", async () => { - mocks.server = failed; - const seen: Array = []; - function Probe(props: { preparing: boolean }) { +function createProbe(seen: Array) { + return function Probe(props: { preparing: boolean }) { const { snapshot } = useWorktreeSetup({ environmentId, threadId, @@ -53,7 +51,13 @@ it("shows the retry's progress after a failed worktree setup", async () => { seen.push(snapshot); }); return null; - } + }; +} + +it("shows the retry's progress after a failed worktree setup", async () => { + mocks.server = failed; + const seen: Array = []; + const Probe = createProbe(seen); const root = createRoot(document.createElement("div")); await act(async () => root.render()); @@ -73,3 +77,33 @@ it("shows the retry's progress after a failed worktree setup", async () => { expect(seen.at(-1)).toBe(retry); act(() => root.unmount()); }); + +it("shows a retry that fails before its first snapshot arrives", async () => { + mocks.server = failed; + const seen: Array = []; + const Probe = createProbe(seen); + + const root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await act(async () => root.render()); + mocks.server = null; + await act(async () => root.render()); + await act(async () => root.render()); + expect(seen.at(-1)).toBe(failed); + + const retryFailed: WorktreeSetupSnapshot = { + ...failed, + sequence: 1, + startedAt: "2026-10-30T00:01:00.000Z", + endedAt: "2026-10-30T00:01:00.050Z", + error: "Checkout failed", + }; + mocks.server = retryFailed; + await act(async () => root.render()); + expect(seen.at(-1)).toBe(retryFailed); + + mocks.server = { ...retryFailed, sequence: 2 }; + await act(async () => root.render()); + expect(seen.at(-1)).toBe(retryFailed); + act(() => root.unmount()); +}); diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts index b2ba067ec2786..7419a8875e8aa 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.ts +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -1,5 +1,8 @@ import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; -import { resolveVisibleWorktreeSetup } from "@t3tools/client-runtime/worktree-setup"; +import { + compareWorktreeSetupSnapshots, + resolveVisibleWorktreeSetup, +} from "@t3tools/client-runtime/worktree-setup"; import { useState } from "react"; import { useEnvironmentQuery } from "../../state/query"; import { vcsEnvironment } from "../../state/vcs"; @@ -17,10 +20,24 @@ export function useWorktreeSetup(input: { const key = JSON.stringify([input.environmentId, input.threadId]); const [held, setHeld] = useState<{ key: string; snapshot: WorktreeSetupSnapshot } | null>(null); const live = held?.key === key ? held.snapshot : null; + const [wasPreparing, setWasPreparing] = useState(input.preparing); + const [retried, setRetried] = useState<{ key: string; after: WorktreeSetupSnapshot } | null>( + null, + ); + if (retried && retried.key !== key) setRetried(null); + if (input.preparing !== wasPreparing) { + setWasPreparing(input.preparing); + // A retry can fail before the reopened read delivers, so read until a newer attempt arrives. + if (input.preparing && live && live.phase !== "running") setRetried({ key, after: live }); + } + const awaitingRetry = retried?.key === key; const query = useEnvironmentQuery( input.environmentId && input.threadId && - (input.preparing || live?.phase === "running" || (!live && input.hasWorktree)) + (input.preparing || + awaitingRetry || + live?.phase === "running" || + (!live && input.hasWorktree)) ? vcsEnvironment.worktreeSetup({ environmentId: input.environmentId, input: { threadId: input.threadId }, @@ -32,6 +49,9 @@ export function useWorktreeSetup(input: { // Set during render so each emission commits once; the resolver returns `live` by identity // on the next pass, which ends the update. if (snapshot && snapshot !== live) setHeld({ key, snapshot }); + if (awaitingRetry && snapshot && compareWorktreeSetupSnapshots(snapshot, retried.after) > 0) { + setRetried(null); + } return { snapshot, visible: resolveVisibleWorktreeSetup({ From 5bf23a9133a3fc72d1d3031cce7d650eff062ef8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:09:16 +0000 Subject: [PATCH 6/7] test(mobile): drop resolver row duplicated by the web logic test AI-assisted; verified with the tests and tools listed above. --- .../src/features/threads/worktree-setup-state.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/mobile/src/features/threads/worktree-setup-state.test.ts b/apps/mobile/src/features/threads/worktree-setup-state.test.ts index 6b8d3b5abc1e3..3598f0000023e 100644 --- a/apps/mobile/src/features/threads/worktree-setup-state.test.ts +++ b/apps/mobile/src/features/threads/worktree-setup-state.test.ts @@ -68,13 +68,6 @@ describe("mobile worktree setup handoff", () => { }, ); - it("takes a later retry even after the server restarts its sequence", () => { - const failed = { ...running, phase: "failed" as const, sequence: 2 }; - const retry = { ...running, startedAt: "2026-09-17T00:01:00.000Z", sequence: 0 }; - expect(resolveWorktreeSetupSnapshot(threadId, retry, failed)).toBe(retry); - expect(resolveWorktreeSetupSnapshot(threadId, failed, retry)).toBe(retry); - }); - it("does not carry a previous thread's progress across navigation", () => { const otherId = ThreadId.make("other-thread"); expect(resolveWorktreeSetupSnapshot(otherId, running, running)).toBeNull(); From 61e33225448efd4d687b605076c9830b79fb1641 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:09:18 +0000 Subject: [PATCH 7/7] fix(mobile): keep reading worktree setup while a retry can follow --- .../threads/use-worktree-setup.test.tsx | 73 ++++++------------- .../features/threads/use-worktree-setup.ts | 27 +------ 2 files changed, 25 insertions(+), 75 deletions(-) diff --git a/apps/mobile/src/features/threads/use-worktree-setup.test.tsx b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx index 262068b2c067f..b58921a921a6e 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.test.tsx +++ b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx @@ -37,73 +37,42 @@ const failed: WorktreeSetupSnapshot = { error: "Fetch base branch failed", }; -function createProbe(seen: Array) { - return function Probe(props: { preparing: boolean }) { - const { snapshot } = useWorktreeSetup({ - environmentId, - threadId, - preparing: props.preparing, - hasWorktree: false, - turnStarted: false, - followUpSent: false, - }); - useLayoutEffect(() => { - seen.push(snapshot); - }); - return null; - }; +function Probe(props: { preparing: boolean; seen: Array }) { + const { snapshot } = useWorktreeSetup({ + environmentId, + threadId, + preparing: props.preparing, + hasWorktree: false, + turnStarted: false, + followUpSent: false, + }); + useLayoutEffect(() => { + props.seen.push(snapshot); + }); + return null; } -it("shows the retry's progress after a failed worktree setup", async () => { +// A fast retry's preparing -> failed can coalesce, so the hook never renders it as preparing. +it("shows a retry's failure that arrives without a preparing render", async () => { mocks.server = failed; const seen: Array = []; - const Probe = createProbe(seen); - const root = createRoot(document.createElement("div")); - await act(async () => root.render()); - await act(async () => root.render()); + await act(async () => root.render()); + await act(async () => root.render()); expect(seen.at(-1)).toBe(failed); - const retry: WorktreeSetupSnapshot = { - ...failed, - sequence: 3, - phase: "running", - startedAt: "2026-10-30T00:01:00.000Z", - endedAt: null, - error: null, - }; - mocks.server = retry; - await act(async () => root.render()); - expect(seen.at(-1)).toBe(retry); - act(() => root.unmount()); -}); - -it("shows a retry that fails before its first snapshot arrives", async () => { - mocks.server = failed; - const seen: Array = []; - const Probe = createProbe(seen); - - const root = createRoot(document.createElement("div")); - await act(async () => root.render()); - await act(async () => root.render()); + // Retention ended, so the server resets the retry's sequence. mocks.server = null; - await act(async () => root.render()); - await act(async () => root.render()); - expect(seen.at(-1)).toBe(failed); - + await act(async () => root.render()); const retryFailed: WorktreeSetupSnapshot = { ...failed, - sequence: 1, + sequence: 0, startedAt: "2026-10-30T00:01:00.000Z", endedAt: "2026-10-30T00:01:00.050Z", error: "Checkout failed", }; mocks.server = retryFailed; - await act(async () => root.render()); - expect(seen.at(-1)).toBe(retryFailed); - - mocks.server = { ...retryFailed, sequence: 2 }; - await act(async () => root.render()); + await act(async () => root.render()); expect(seen.at(-1)).toBe(retryFailed); act(() => root.unmount()); }); diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts index 7419a8875e8aa..e9a7abc43db20 100644 --- a/apps/mobile/src/features/threads/use-worktree-setup.ts +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -1,8 +1,5 @@ import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; -import { - compareWorktreeSetupSnapshots, - resolveVisibleWorktreeSetup, -} from "@t3tools/client-runtime/worktree-setup"; +import { resolveVisibleWorktreeSetup } from "@t3tools/client-runtime/worktree-setup"; import { useState } from "react"; import { useEnvironmentQuery } from "../../state/query"; import { vcsEnvironment } from "../../state/vcs"; @@ -12,7 +9,7 @@ export function useWorktreeSetup(input: { environmentId: EnvironmentId | null; threadId: ThreadId | null; preparing: boolean; - /** Reads a worktree thread's snapshot until one is held; after that only `preparing` reopens it. */ + /** Reads a worktree thread's snapshot until one is held. */ hasWorktree: boolean; turnStarted: boolean; followUpSent: boolean; @@ -20,24 +17,11 @@ export function useWorktreeSetup(input: { const key = JSON.stringify([input.environmentId, input.threadId]); const [held, setHeld] = useState<{ key: string; snapshot: WorktreeSetupSnapshot } | null>(null); const live = held?.key === key ? held.snapshot : null; - const [wasPreparing, setWasPreparing] = useState(input.preparing); - const [retried, setRetried] = useState<{ key: string; after: WorktreeSetupSnapshot } | null>( - null, - ); - if (retried && retried.key !== key) setRetried(null); - if (input.preparing !== wasPreparing) { - setWasPreparing(input.preparing); - // A retry can fail before the reopened read delivers, so read until a newer attempt arrives. - if (input.preparing && live && live.phase !== "running") setRetried({ key, after: live }); - } - const awaitingRetry = retried?.key === key; const query = useEnvironmentQuery( input.environmentId && input.threadId && - (input.preparing || - awaitingRetry || - live?.phase === "running" || - (!live && input.hasWorktree)) + // A retry can follow any unfinished attempt, and its `preparing` can clear before a render sees it. + (input.preparing || (live ? live.phase !== "done" : input.hasWorktree)) ? vcsEnvironment.worktreeSetup({ environmentId: input.environmentId, input: { threadId: input.threadId }, @@ -49,9 +33,6 @@ export function useWorktreeSetup(input: { // Set during render so each emission commits once; the resolver returns `live` by identity // on the next pass, which ends the update. if (snapshot && snapshot !== live) setHeld({ key, snapshot }); - if (awaitingRetry && snapshot && compareWorktreeSetupSnapshots(snapshot, retried.after) > 0) { - setRetried(null); - } return { snapshot, visible: resolveVisibleWorktreeSetup({