From 6a96b52b1d418ea934009b2f8c05bb8b6c81d489 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 04:54:04 +0000 Subject: [PATCH 1/8] test: reproduce #29062 Initialize Git offered for a missing worktree These tests reproduce #29062. The server test expects initRepository on a folder that does not exist to fail with an error saying so, without creating it. The web test expects a thread whose worktree reports a non-repository status not to offer Initialize Git, next to a control where a project-root thread on a non-repository folder still does. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriver.test.ts | 15 ++++ .../src/components/ChatView.browser.test.tsx | 69 ++++++++++++++++++- apps/web/test/fakeEnvironment.ts | 3 +- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index ba2b4abe50af7..c9218b181ff8a 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -132,6 +132,21 @@ it.effect("lists the fetch and push URLs of a partial clone remote", () => }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); +it.effect("refuses to initialize a repository in a folder that does not exist", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-init-missing-" }); + const cwd = path.join(parent, "removed-worktree"); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + const error = yield* driver.initRepository({ cwd }).pipe(Effect.flip); + + assert.match(error.message, /does not exist/i); + assert.isFalse(yield* fileSystem.exists(cwd)); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( driver: Effect.Success>, cwd: string, diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 96eb837db4d69..55cbd9af74757 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -1,5 +1,5 @@ import { createMemoryHistory } from "@tanstack/react-router"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -12,14 +12,18 @@ import { AppRoot } from "~/AppRoot"; import "~/components/ChatView"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter } from "~/router"; import { ENVIRONMENT_ID, PROJECT_ID, + emitVcsStatus, installFakeEnvironment, + threadWithHistory, type FakeDriver, + type FakeThread, } from "../../test/fakeEnvironment"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; // The socket is the only fake layer; see test/appScenarios.renders.tsx. vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { @@ -65,3 +69,64 @@ it("a new draft does not load its reserved thread before the server lists it", a // A 404 here marks the thread deleted, and the view never loads it once the send creates it. expect(threadRequests).toEqual([]); }); + +async function openThreadDetailsOnNonRepository(thread: FakeThread) { + const stack = new AsyncDisposableStack(); + try { + stack.use(installFakeEnvironment([thread])); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const threadRef = scopeThreadRef(ENVIRONMENT_ID, thread.id); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", true); + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", true); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + const container = document.createElement("div"); + container.style.cssText = "height: 900px; width: 1400px;"; + // AppRoot brings its own router. + stack.use(await render(, { container, router: false })); + await expect.element(page.getByText(`${thread.id}: question 0`)).toBeVisible(); + // vcs.status reports a missing folder as a non-repository. + await vi.waitFor(() => + act(() => + emitVcsStatus({ + _tag: "snapshot", + local: { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + remote: null, + }), + ), + ); + return stack.move(); + } catch (error) { + await stack.disposeAsync(); + throw error; + } +} + +it("offers Initialize Git for a project folder that is not a repository", async () => { + await using _app = await openThreadDetailsOnNonRepository(threadWithHistory("root-thread", 1)); + + await expect.element(page.getByRole("button", { name: "Initialize Git" })).toBeVisible(); +}); + +it("does not offer Initialize Git when the thread's worktree folder is missing", async () => { + await using _app = await openThreadDetailsOnNonRepository({ + ...threadWithHistory("worktree-thread", 1), + worktreePath: "/workspace/worktrees/render/t3-removed", + }); + + await expect + .element(page.getByRole("button", { name: "Initialize Git" })) + .not.toBeInTheDocument(); +}); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 44cd45d127e11..a4ff995fca3b8 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -187,6 +187,7 @@ export interface FakeThread { readonly sideChatOf?: ThreadId; readonly id: ThreadId; readonly title: string; + readonly worktreePath?: string; readonly runs: ReadonlyArray; readonly items: ReadonlyArray; } @@ -225,7 +226,7 @@ function shellOf(thread: FakeThread): OrchestrationV2ThreadShell { runtimeMode: "full-access", interactionMode: "default", branch: "main", - worktreePath: null, + worktreePath: thread.worktreePath ?? null, activeProviderThreadId: null, lineage: { rootThreadId: thread.sideChatOf ?? thread.id, From fa8476bc923fb0d8bce8f912e4ae9b7fe5a871b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 04:59:58 +0000 Subject: [PATCH 2/8] fix: stop offering Initialize Git for a thread whose worktree folder is gone A deleted worktree reports as a non-repository, so the thread details panel offered Initialize Git, which then failed with a raw git exit 128. Threads with a worktree now show a non-actionable "Worktree missing" row instead, and GitVcsDriver.initRepository refuses a folder that does not exist rather than running git there. Fixes pingdotgg/t3code#29062 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriver.ts | 19 ++++++++++++++++--- apps/web/src/components/GitActionsControl.tsx | 12 +++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b6f5fbbf496a9..45e832e1867e3 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -13,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process"; import { GitCommandError, VcsProcessExitError, + VcsRepositoryDetectionError, VcsUnsupportedOperationError, type VcsSwitchRefInput, type VcsSwitchRefResult, @@ -776,11 +777,23 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); }); - const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = (input) => - gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { + const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = Effect.fn( + "GitVcsDriver.initRepository", + )(function* (input) { + // A deleted worktree reports as a non-repository; `git init` there would recreate it. + const exists = yield* fileSystem.exists(input.cwd).pipe(Effect.orElseSucceed(() => true)); + if (!exists) { + return yield* new VcsRepositoryDetectionError({ + operation: "GitVcsDriver.initRepository", + cwd: input.cwd, + detail: "The folder does not exist.", + }); + } + yield* gitCommand(vcsProcess, "GitVcsDriver.initRepository", input.cwd, ["init"], { timeoutMs: 10_000, maxOutputBytes: 64 * 1024, - }).pipe(Effect.asVoid); + }); + }); const resolveHeadCommit = (cwd: string) => execute({ diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6afb76c3760e9..fbb895a02793e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -31,6 +31,7 @@ import { CloudDownloadIcon, CloudUploadIcon, FileDiffIcon, + FolderXIcon, GitBranchPlusIcon, GitCommitIcon, InfoIcon, @@ -965,6 +966,9 @@ export default function GitActionsControl({ const activeServerThread = activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null; const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null; + const activeWorktreePath = activeServerThread + ? activeServerThread.worktreePath + : (activeDraftThread?.worktreePath ?? null); const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); @@ -1484,7 +1488,13 @@ export default function GitActionsControl({ return ( <> - {!showActions ? null : !isRepo ? ( + {!showActions ? null : !isRepo && activeWorktreePath !== null ? ( + // A worktree is always a checkout, so a non-repository status means its folder is gone. + + + Worktree missing + + ) : !isRepo ? ( Date: Mon, 2 Nov 2026 05:08:47 +0000 Subject: [PATCH 3/8] fix: tighten missing-worktree test and comments Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriver.ts | 2 +- apps/web/src/components/ChatView.browser.test.tsx | 1 + apps/web/src/components/GitActionsControl.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 45e832e1867e3..4c42a56fe21c8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -780,7 +780,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const initRepository: VcsDriver.VcsDriver["Service"]["initRepository"] = Effect.fn( "GitVcsDriver.initRepository", )(function* (input) { - // A deleted worktree reports as a non-repository; `git init` there would recreate it. + // A deleted folder reports as a non-repository; say so instead of git's bare exit 128. const exists = yield* fileSystem.exists(input.cwd).pipe(Effect.orElseSucceed(() => true)); if (!exists) { return yield* new VcsRepositoryDetectionError({ diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 55cbd9af74757..b97510da96c6d 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -126,6 +126,7 @@ it("does not offer Initialize Git when the thread's worktree folder is missing", worktreePath: "/workspace/worktrees/render/t3-removed", }); + await expect.element(page.getByText("Worktree missing")).toBeVisible(); await expect .element(page.getByRole("button", { name: "Initialize Git" })) .not.toBeInTheDocument(); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index fbb895a02793e..7c7611d28c80e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1489,7 +1489,7 @@ export default function GitActionsControl({ return ( <> {!showActions ? null : !isRepo && activeWorktreePath !== null ? ( - // A worktree is always a checkout, so a non-repository status means its folder is gone. + // A worktree is always a checkout, so a non-repository status means it was removed. Worktree missing From d8f6a57ae77d1d7b30ec566d6cd3ab13bfc3ce58 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 05:47:57 +0000 Subject: [PATCH 4/8] fix(web): keep Initialize Git for No project scratch folders No project threads store their plain scratch folder in worktreePath, so a non-repository status there is not a missing worktree. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 16 ++++++++++ apps/web/src/components/GitActionsControl.tsx | 20 +++++++++--- apps/web/test/fakeEnvironment.ts | 31 ++++++++++++++++--- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index b97510da96c6d..1e33fae908120 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -17,6 +17,7 @@ import { getRouter } from "~/router"; import { ENVIRONMENT_ID, PROJECT_ID, + emitServerConfig, emitVcsStatus, installFakeEnvironment, threadWithHistory, @@ -131,3 +132,18 @@ it("does not offer Initialize Git when the thread's worktree folder is missing", .element(page.getByRole("button", { name: "Initialize Git" })) .not.toBeInTheDocument(); }); + +it("keeps Initialize Git for a No project thread's scratch folder", async () => { + // No project threads store their plain scratch folder as worktreePath. + await using _app = await openThreadDetailsOnNonRepository({ + ...threadWithHistory("scratch-thread", 1), + worktreePath: "/workspace/render/2026-11-02-x", + }); + await using _scratch = { + [Symbol.asyncDispose]: () => act(() => emitServerConfig()), + }; + await act(() => emitServerConfig({ scratchWorkspaceRoot: "/workspace/render" })); + + await expect.element(page.getByRole("button", { name: "Initialize Git" })).toBeVisible(); + await expect.element(page.getByText("Worktree missing")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7c7611d28c80e..2070e12562da8 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -91,7 +91,9 @@ import { useVcsInitAction, useVcsPullAction, } from "../state/sourceControlActions"; -import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { useProject, useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -966,9 +968,17 @@ export default function GitActionsControl({ const activeServerThread = activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null; const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null; - const activeWorktreePath = activeServerThread - ? activeServerThread.worktreePath - : (activeDraftThread?.worktreePath ?? null); + const activeThreadContext = activeServerThread ?? activeDraftThread; + const activeProject = useProject( + activeEnvironmentId !== null && activeThreadContext !== null + ? scopeProjectRef(activeEnvironmentId, activeThreadContext.projectId) + : null, + ); + // No project threads keep a plain scratch folder in worktreePath, not a checkout. + const isWorktreeThread = + activeThreadContext?.worktreePath != null && + activeProject !== null && + !isScratchProject(activeProject, serverConfig?.scratchWorkspaceRoot); const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); @@ -1488,7 +1498,7 @@ export default function GitActionsControl({ return ( <> - {!showActions ? null : !isRepo && activeWorktreePath !== null ? ( + {!showActions ? null : !isRepo && isWorktreeThread ? ( // A worktree is always a checkout, so a non-repository status means it was removed. diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index a4ff995fca3b8..0aba39854f849 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -17,6 +17,7 @@ import { TurnItemId, WS_METHODS, WsRpcGroup, + type ServerConfigStreamEvent, type VcsStatusStreamEvent, type OrchestrationProjectShell, type OrchestrationV2ProjectedTurnItem, @@ -321,6 +322,7 @@ let terminalMetadataSubscriber: Queue.Queue | null let threads: ReadonlyArray = []; let sequence = 1; let vcsStatusSubscriber: Queue.Queue | null = null; +let serverConfigSubscriber: Queue.Queue | null = null; const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue) => () => void) => Stream.unwrap( @@ -335,6 +337,17 @@ const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue }), ); +const subscribeServerConfig = () => + subscribe( + [{ version: 1, type: "snapshot", config: serverConfig }], + (queue) => { + serverConfigSubscriber = queue; + return () => { + if (serverConfigSubscriber === queue) serverConfigSubscriber = null; + }; + }, + ); + const handlers: Record unknown> = { [WS_METHODS.subscribeTerminalMetadata]: () => subscribe([{ type: "snapshot", terminals: [] }], (queue) => { @@ -382,8 +395,7 @@ const handlers: Record unknown> = { if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null; }; }), - [WS_METHODS.subscribeServerConfig]: () => - subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig }]), + [WS_METHODS.subscribeServerConfig]: () => subscribeServerConfig(), }; // Unanswered calls stay pending, like a server that has not replied yet. @@ -399,8 +411,7 @@ const client = new Proxy(handlers, { const session: EnvironmentConnectionLease["session"] = { client: client as unknown as WsRpcProtocolClient, initialConfig: Effect.succeed(serverConfig), - subscribeServerConfig: () => - subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig }]), + subscribeServerConfig: () => subscribeServerConfig(), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), @@ -500,6 +511,18 @@ export function runningRun(threadId: ThreadId, id: string, ordinal: number, user export { userItem }; +/** Replaces the server config the app holds; `undefined` restores the default. */ +export async function emitServerConfig(overrides?: Partial): Promise { + if (serverConfigSubscriber === null) throw new Error("No server config subscription"); + await Effect.runPromise( + Queue.offer(serverConfigSubscriber, { + version: 1, + type: "snapshot", + config: { ...serverConfig, ...overrides }, + }), + ); +} + export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise { if (vcsStatusSubscriber === null) throw new Error("No VCS status subscription"); await Effect.runPromise(Queue.offer(vcsStatusSubscriber, event)); From 6e08d0397366703a644d9152eb946aaa267d0ec8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 05:52:30 +0000 Subject: [PATCH 5/8] fix(server): report a failed folder check before git init Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriver.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 4c42a56fe21c8..b6219d396f6d2 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -781,7 +781,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "GitVcsDriver.initRepository", )(function* (input) { // A deleted folder reports as a non-repository; say so instead of git's bare exit 128. - const exists = yield* fileSystem.exists(input.cwd).pipe(Effect.orElseSucceed(() => true)); + const exists = yield* fileSystem.exists(input.cwd).pipe( + Effect.mapError( + (cause) => + new VcsRepositoryDetectionError({ + operation: "GitVcsDriver.initRepository", + cwd: input.cwd, + detail: "Could not check that the folder exists.", + cause, + }), + ), + ); if (!exists) { return yield* new VcsRepositoryDetectionError({ operation: "GitVcsDriver.initRepository", From 37567a3bc37c6b83d4540293903d5208ad730a00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 05:56:50 +0000 Subject: [PATCH 6/8] refactor(web): read the scratch flag from the project entity Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/GitActionsControl.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2070e12562da8..1abe43279e76e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -93,7 +93,6 @@ import { } from "../state/sourceControlActions"; import { useProject, useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -978,7 +977,7 @@ export default function GitActionsControl({ const isWorktreeThread = activeThreadContext?.worktreePath != null && activeProject !== null && - !isScratchProject(activeProject, serverConfig?.scratchWorkspaceRoot); + activeProject.isScratch !== true; const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); From 10e729f3de489487d52f01aedeb4232682eb22b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:08:50 +0000 Subject: [PATCH 7/8] test(web): drop the plain-folder case the scratch case already covers AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/ChatView.browser.test.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index e8b500c0cf511..b8b9ecc106b27 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -115,12 +115,6 @@ async function openThreadDetailsOnNonRepository(thread: FakeThread) { } } -it("offers Initialize Git for a project folder that is not a repository", async () => { - await using _app = await openThreadDetailsOnNonRepository(threadWithHistory("root-thread", 1)); - - await expect.element(page.getByRole("button", { name: "Initialize Git" })).toBeVisible(); -}); - it("does not offer Initialize Git when the thread's worktree folder is missing", async () => { await using _app = await openThreadDetailsOnNonRepository({ ...threadWithHistory("worktree-thread", 1), From a24852eda71c0a68e4a7b0bec3f9453a150785b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 00:11:13 +0000 Subject: [PATCH 8/8] test(web): send the missing-worktree status to the thread's own folder The fake environment delivered VCS status only to the most recent subscription. The thread details panel subscribes for the worktree and the project root, so when the worktree test ran first its non-repository snapshot reached the project root and the test hung. emitVcsStatus now takes an optional cwd. Also simplify the scratch check in GitActionsControl. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 27 ++++++++++--------- apps/web/src/components/GitActionsControl.tsx | 4 +-- apps/web/test/fakeEnvironment.ts | 19 ++++++++----- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 876fdb2e633b2..b9eda7af5aacb 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -72,7 +72,7 @@ it("a new draft does not load its reserved thread before the server lists it", a expect(threadRequests).toEqual([]); }); -async function openThreadDetailsOnNonRepository(thread: FakeThread) { +async function openThreadDetailsOnNonRepository(thread: FakeThread & { worktreePath: string }) { const stack = new AsyncDisposableStack(); try { stack.use(installFakeEnvironment([thread])); @@ -95,18 +95,21 @@ async function openThreadDetailsOnNonRepository(thread: FakeThread) { // vcs.status reports a missing folder as a non-repository. await vi.waitFor(() => act(() => - emitVcsStatus({ - _tag: "snapshot", - local: { - isRepo: false, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, + emitVcsStatus( + { + _tag: "snapshot", + local: { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + remote: null, }, - remote: null, - }), + thread.worktreePath, + ), ), ); return stack.move(); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1b11823c64b16..ff4c5f68536ee 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -987,9 +987,7 @@ export default function GitActionsControl({ ); // No project threads keep a plain scratch folder in worktreePath, not a checkout. const isWorktreeThread = - activeThreadContext?.worktreePath != null && - activeProject !== null && - activeProject.isScratch !== true; + activeThreadContext?.worktreePath != null && activeProject !== null && !activeProject.isScratch; const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index a35da09d20650..eb7570e8478b5 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -334,7 +334,7 @@ let threads: ReadonlyArray = []; let dispatched: OrchestrationV2Command[] = []; let acceptCommands = false; let sequence = 1; -let vcsStatusSubscriber: Queue.Queue | null = null; +const vcsStatusSubscribers = new Map>(); let serverConfigSubscriber: Queue.Queue | null = null; const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue) => () => void) => @@ -418,11 +418,12 @@ const handlers: Record unknown> = { : [], }); }, - [WS_METHODS.subscribeVcsStatus]: () => + [WS_METHODS.subscribeVcsStatus]: ({ cwd }: { readonly cwd: string }) => subscribe([], (queue) => { - vcsStatusSubscriber = queue; + vcsStatusSubscribers.delete(cwd); + vcsStatusSubscribers.set(cwd, queue); return () => { - if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null; + if (vcsStatusSubscribers.get(cwd) === queue) vcsStatusSubscribers.delete(cwd); }; }), [WS_METHODS.subscribeServerConfig]: () => subscribeServerConfig(), @@ -565,7 +566,11 @@ export async function emitServerConfig(overrides?: Partial): Promi ); } -export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise { - if (vcsStatusSubscriber === null) throw new Error("No VCS status subscription"); - await Effect.runPromise(Queue.offer(vcsStatusSubscriber, event)); +/** Sends to the subscription for `cwd`, or to the most recent one. */ +export async function emitVcsStatus(event: VcsStatusStreamEvent, cwd?: string): Promise { + const subscriber = + cwd === undefined ? [...vcsStatusSubscribers.values()].at(-1) : vcsStatusSubscribers.get(cwd); + if (subscriber === undefined) + throw new Error(`No VCS status subscription for ${cwd ?? "any cwd"}`); + await Effect.runPromise(Queue.offer(subscriber, event)); }