diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index ab0b5cdb9bd4d..efa8cda9a0469 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -836,9 +836,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 fc72af2f06b0f..9bd65c69a3427 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -789,7 +789,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 new file mode 100644 index 0000000000000..b58921a921a6e --- /dev/null +++ b/apps/mobile/src/features/threads/use-worktree-setup.test.tsx @@ -0,0 +1,78 @@ +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", +}; + +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; +} + +// 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 root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await act(async () => root.render()); + expect(seen.at(-1)).toBe(failed); + + // Retention ended, so the server resets the retry's sequence. + mocks.server = null; + await act(async () => root.render()); + const retryFailed: WorktreeSetupSnapshot = { + ...failed, + 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); + 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 5972dc7b28180..e9a7abc43db20 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 snapshot until one is held. */ + hasWorktree: boolean; turnStarted: boolean; followUpSent: boolean; }) { @@ -18,7 +20,8 @@ export function useWorktreeSetup(input: { const query = useEnvironmentQuery( input.environmentId && input.threadId && - (live?.phase === "running" || (!live && input.preparing)) + // 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 }, 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/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 046a2dc97b236..27798f31c780c 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -39,6 +39,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/layout/workspace/useSearchScrollRestoration.test.tsx", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7de02cc3941ad..1660c81cca101 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -721,6 +721,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 09a25ee55910d..a77be707ecca1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -42,7 +42,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 { @@ -299,7 +302,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;