From 6aa1871e35ba17abf5025bccd946cc76bb018701 Mon Sep 17 00:00:00 2001 From: SAKETH11111 Date: Sat, 11 Apr 2026 09:58:53 -0500 Subject: [PATCH 1/2] Handle non-git drafts in local mode --- .../components/BranchToolbar.logic.test.ts | 11 ++ .../web/src/components/BranchToolbar.logic.ts | 6 +- apps/web/src/components/ChatView.browser.tsx | 108 +++++++++++++++++- apps/web/src/components/ChatView.tsx | 31 +++++ 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index faf21d636c..2839f15735 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -138,6 +138,17 @@ describe("resolveEffectiveEnvMode", () => { }), ).toBe("worktree"); }); + + it("falls back to local mode for non-git projects even if the draft prefers worktree mode", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: false, + draftThreadEnvMode: "worktree", + isGitRepo: false, + }), + ).toBe("local"); + }); }); describe("resolveEnvModeLabel", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 54ec4370f4..30d549512d 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -54,8 +54,12 @@ export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; draftThreadEnvMode: EnvMode | undefined; + isGitRepo?: boolean; }): EnvMode { - const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input; + const { activeWorktreePath, hasServerThread, draftThreadEnvMode, isGitRepo = true } = input; + if (!isGitRepo) { + return "local"; + } if (!hasServerThread) { if (activeWorktreePath) { return "local"; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bfb2b95ba2..6242537ed5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -49,8 +49,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +const { gitStatusStateRef } = vi.hoisted(() => ({ + gitStatusStateRef: { + current: { data: null, error: null, cause: null, isPending: false } as { + data: unknown; + error: unknown; + cause: unknown; + isPending: boolean; + }, + }, +})); + vi.mock("../lib/gitStatusState", () => ({ - useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatus: () => gitStatusStateRef.current, useGitStatuses: () => new Map(), refreshGitStatus: () => Promise.resolve(null), resetGitStatusStateForTests: () => undefined, @@ -1458,6 +1469,7 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; + gitStatusStateRef.current = { data: null, error: null, cause: null, isPending: false }; useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, @@ -2448,6 +2460,100 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("falls back to local send behavior for non-git drafts even when worktree mode is persisted", async () => { + gitStatusStateRef.current = { + data: { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }, + error: null, + cause: null, + isPending: false, + }; + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ) as + | { + _tag: string; + bootstrap?: { + createThread?: { projectId?: string }; + prepareWorktree?: unknown; + }; + } + | undefined; + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + bootstrap: { + createThread: { + projectId: PROJECT_ID, + }, + }, + }); + expect(dispatchRequest?.bootstrap?.prepareWorktree).toBeUndefined(); + expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect(useComposerDraftStore.getState().getDraftThread(THREAD_REF)?.envMode ?? null).toBe( + "local", + ); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44ad594ff9..6063570ec7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2196,7 +2196,38 @@ export default function ChatView(props: ChatViewProps) { activeWorktreePath, hasServerThread: isServerThread, draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, + isGitRepo, }); + const draftThreadEnvMode = draftThread?.envMode; + const draftThreadBranch = draftThread?.branch; + + useEffect(() => { + if (!isLocalDraftThread) { + return; + } + if (!draftThread) { + return; + } + if (gitStatusQuery.data?.isRepo !== false) { + return; + } + if (draftThreadEnvMode !== "worktree" && draftThreadBranch === null) { + return; + } + setDraftThreadContext(composerDraftTarget, { + envMode: "local", + branch: null, + worktreePath: null, + }); + }, [ + composerDraftTarget, + draftThread, + draftThreadBranch, + draftThreadEnvMode, + gitStatusQuery.data?.isRepo, + isLocalDraftThread, + setDraftThreadContext, + ]); useEffect(() => { if (!activeThreadId) { From f56bf0c2b959934c64cf3be759822fd312a05a56 Mon Sep 17 00:00:00 2001 From: SAKETH11111 Date: Sat, 11 Apr 2026 10:19:09 -0500 Subject: [PATCH 2/2] Refresh git status before worktree send guard --- apps/web/src/components/ChatView.browser.tsx | 116 ++++++++++++++++++- apps/web/src/components/ChatView.tsx | 48 ++++++-- 2 files changed, 152 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6242537ed5..e20e9f26ff 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -49,7 +49,7 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -const { gitStatusStateRef } = vi.hoisted(() => ({ +const { gitStatusStateRef, refreshGitStatusResultRef, refreshGitStatusSpy } = vi.hoisted(() => ({ gitStatusStateRef: { current: { data: null, error: null, cause: null, isPending: false } as { data: unknown; @@ -58,12 +58,14 @@ const { gitStatusStateRef } = vi.hoisted(() => ({ isPending: boolean; }, }, + refreshGitStatusResultRef: { current: null as unknown }, + refreshGitStatusSpy: vi.fn(() => Promise.resolve(refreshGitStatusResultRef.current)), })); vi.mock("../lib/gitStatusState", () => ({ useGitStatus: () => gitStatusStateRef.current, useGitStatuses: () => new Map(), - refreshGitStatus: () => Promise.resolve(null), + refreshGitStatus: refreshGitStatusSpy, resetGitStatusStateForTests: () => undefined, })); @@ -1470,6 +1472,8 @@ describe("ChatView timeline estimator parity (full app)", () => { wsRequests.length = 0; customWsRpcResolver = null; gitStatusStateRef.current = { data: null, error: null, cause: null, isPending: false }; + refreshGitStatusResultRef.current = null; + refreshGitStatusSpy.mockClear(); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, @@ -2461,6 +2465,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("falls back to local send behavior for non-git drafts even when worktree mode is persisted", async () => { + const draftId = draftIdFromPath("/draft/draft-non-git-local-fallback"); gitStatusStateRef.current = { data: { isRepo: false, @@ -2480,7 +2485,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }; useComposerDraftStore.setState({ draftThreadsByThreadKey: { - [THREAD_KEY]: { + [draftId]: { threadId: THREAD_ID, environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, @@ -2494,14 +2499,18 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }, logicalProjectDraftThreadKeyByLogicalProjectKey: { - [PROJECT_DRAFT_KEY]: THREAD_KEY, + [PROJECT_DRAFT_KEY]: draftId, }, }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${draftId}`, resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitListBranches) { + return new Promise(() => {}); + } if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { return { sequence: fixture.snapshot.snapshotSequence + 1, @@ -2554,6 +2563,105 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("refreshes pending git status before tripping the worktree base-branch guard", async () => { + const draftId = draftIdFromPath("/draft/draft-non-git-race"); + gitStatusStateRef.current = { + data: null, + error: null, + cause: null, + isPending: true, + }; + refreshGitStatusResultRef.current = { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [draftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: draftId, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${draftId}`, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitListBranches) { + return new Promise(() => {}); + } + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + expect(refreshGitStatusSpy).toHaveBeenCalledWith({ + environmentId: LOCAL_ENVIRONMENT_ID, + cwd: "/repo/project", + }); + const dispatchRequest = wsRequests.find( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ) as + | { + _tag: string; + bootstrap?: { + createThread?: { projectId?: string }; + prepareWorktree?: unknown; + }; + } + | undefined; + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + bootstrap: { + createThread: { + projectId: PROJECT_ID, + }, + }, + }); + expect(dispatchRequest?.bootstrap?.prepareWorktree).toBeUndefined(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6063570ec7..2262ae5ac1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -31,7 +31,7 @@ import { truncate } from "@t3tools/shared/String"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; -import { useGitStatus } from "~/lib/gitStatusState"; +import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; @@ -2547,16 +2547,48 @@ export default function ChatView(props: ChatViewProps) { if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; - const baseBranchForWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath - ? activeThread.branch - : null; + let envModeForSend = envMode; + let threadBranchForSend = activeThread.branch; + + if ( + isFirstMessage && + envMode === "worktree" && + !activeThread.worktreePath && + !threadBranchForSend && + gitCwd + ) { + const latestGitStatus = await refreshGitStatus({ + environmentId, + cwd: gitCwd, + }).catch(() => gitStatusQuery.data); + + if (latestGitStatus?.isRepo === false) { + envModeForSend = "local"; + threadBranchForSend = null; + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + envMode: "local", + branch: null, + worktreePath: null, + }); + } + } else if (latestGitStatus?.branch) { + threadBranchForSend = latestGitStatus.branch; + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + envMode: "worktree", + branch: latestGitStatus.branch, + }); + } + } + } // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThread.branch) { + isFirstMessage && envModeForSend === "worktree" && !activeThread.worktreePath; + const baseBranchForWorktree = shouldCreateWorktree ? threadBranchForSend : null; + if (shouldCreateWorktree && !threadBranchForSend) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -2690,7 +2722,7 @@ export default function ChatView(props: ChatViewProps) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThread.branch, + branch: threadBranchForSend, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, },