From c55c17bcc83c48d1df6d81c333f9a2c657299991 Mon Sep 17 00:00:00 2001 From: Kainoa Newton Date: Thu, 12 Mar 2026 19:45:58 -0700 Subject: [PATCH 1/2] Add default new-thread worktree mode with draft defaults - add `newThreadUsesNewWorktree` app setting and Threads mode selector in Settings - centralize new-thread draft initialization in `buildNewThreadDraftDefaults` - make Sidebar new-thread flow use resolved defaults (with force-local shortcut support) - backfill settings parsing for older persisted payloads and add coverage for worktree-first send flow --- apps/web/src/appSettings.test.ts | 68 +++++++++- apps/web/src/appSettings.ts | 15 ++- apps/web/src/components/ChatView.browser.tsx | 126 +++++++++++++++++-- apps/web/src/components/Sidebar.tsx | 125 +++++++++--------- apps/web/src/routes/_chat.settings.tsx | 55 ++++++++ apps/web/src/threadDraftDefaults.test.ts | 84 +++++++++++++ apps/web/src/threadDraftDefaults.ts | 43 +++++++ 7 files changed, 448 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/threadDraftDefaults.test.ts create mode 100644 apps/web/src/threadDraftDefaults.ts diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 213e4cd3d..64c596bf6 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,12 +1,78 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { + APP_SETTINGS_STORAGE_KEY, + getAppSettingsSnapshot, getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, } from "./appSettings"; +function getWindowForTest(): Window & typeof globalThis { + const testGlobal = globalThis as typeof globalThis & { + window?: Window & typeof globalThis; + }; + if (!testGlobal.window) { + testGlobal.window = {} as Window & typeof globalThis; + } + return testGlobal.window; +} + +function createStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key) { + return values.get(key) ?? null; + }, + key(index) { + return [...values.keys()][index] ?? null; + }, + removeItem(key) { + values.delete(key); + }, + setItem(key, value) { + values.set(key, value); + }, + }; +} + +beforeEach(() => { + const testWindow = getWindowForTest(); + Object.defineProperty(testWindow, "localStorage", { + configurable: true, + value: createStorage(), + }); + testWindow.addEventListener = vi.fn(); + testWindow.removeEventListener = vi.fn(); +}); + +describe("getAppSettingsSnapshot", () => { + it("includes newThreadUsesNewWorktree in the default settings", () => { + expect(getAppSettingsSnapshot().newThreadUsesNewWorktree).toBe(false); + }); + + it("applies schema defaults when older persisted payloads omit new settings", () => { + getWindowForTest().localStorage.setItem( + APP_SETTINGS_STORAGE_KEY, + JSON.stringify({ + confirmThreadDelete: false, + }), + ); + + expect(getAppSettingsSnapshot()).toMatchObject({ + confirmThreadDelete: false, + newThreadUsesNewWorktree: false, + }); + }); +}); + describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { expect( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb2..90d5eaa78 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -3,7 +3,7 @@ import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; +export const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { @@ -21,6 +21,9 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + newThreadUsesNewWorktree: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), @@ -174,7 +177,15 @@ function parsePersistedSettings(value: string | null): AppSettings { } try { - return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); + const parsed = JSON.parse(value); + const persistedSettings = + parsed && typeof parsed === "object" + ? Schema.decodeSync(AppSettingsSchema)({ + ...DEFAULT_APP_SETTINGS, + ...parsed, + }) + : DEFAULT_APP_SETTINGS; + return normalizeAppSettings(persistedSettings); } catch { return DEFAULT_APP_SETTINGS; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc..6da13e01c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -353,14 +353,14 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function resolveWsRpc(tag: string): unknown { - if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { +function resolveWsRpc(request: WsRequestEnvelope["body"]): unknown { + if (request._tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } - if (tag === WS_METHODS.serverGetConfig) { + if (request._tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } - if (tag === WS_METHODS.gitListBranches) { + if (request._tag === WS_METHODS.gitListBranches) { return { isRepo: true, hasOriginRemote: true, @@ -374,7 +374,7 @@ function resolveWsRpc(tag: string): unknown { ], }; } - if (tag === WS_METHODS.gitStatus) { + if (request._tag === WS_METHODS.gitStatus) { return { branch: "main", hasWorkingTreeChanges: false, @@ -389,7 +389,18 @@ function resolveWsRpc(tag: string): unknown { pr: null, }; } - if (tag === WS_METHODS.projectsSearchEntries) { + if (request._tag === WS_METHODS.gitCreateWorktree) { + return { + worktree: { + branch: typeof request.newBranch === "string" ? request.newBranch : "t3code/browser-test", + path: + typeof request.path === "string" + ? request.path + : "/repo/project/.t3/worktrees/browser-test", + }, + }; + } + if (request._tag === WS_METHODS.projectsSearchEntries) { return { entries: [], truncated: false, @@ -417,13 +428,12 @@ const worker = setupWorker( } catch { return; } - const method = request.body?._tag; - if (typeof method !== "string") return; + if (typeof request.body?._tag !== "string") return; wsRequests.push(request.body); client.send( JSON.stringify({ id: request.id, - result: resolveWsRpc(method), + result: resolveWsRpc(request.body), }), ); }); @@ -526,6 +536,17 @@ async function waitForInteractionModeButton( ); } +function findDispatchCommandRequest(commandType: string) { + return wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof request.command === "object" && + request.command !== null && + "type" in request.command && + request.command.type === commandType, + ); +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); if (images.length === 0) { @@ -1048,6 +1069,93 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("creates the worktree on first send for a pending worktree draft", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + }); + + try { + const composerEditor = page.getByTestId("composer-editor"); + await composerEditor.fill("Ship the change"); + await page.getByRole("button", { name: "Send message" }).click(); + + let worktreeNewBranch: string | null = null; + await vi.waitFor( + () => { + const worktreeRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.gitCreateWorktree, + ); + expect(worktreeRequest).toMatchObject({ + _tag: WS_METHODS.gitCreateWorktree, + cwd: "/repo/project", + branch: "main", + }); + worktreeNewBranch = + worktreeRequest && typeof worktreeRequest.newBranch === "string" + ? worktreeRequest.newBranch + : null; + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const threadCreateRequest = findDispatchCommandRequest("thread.create"); + expect(threadCreateRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + command: { + type: "thread.create", + threadId: THREAD_ID, + projectId: PROJECT_ID, + worktreePath: "/repo/project/.t3/worktrees/browser-test", + }, + }); + expect( + threadCreateRequest && + typeof threadCreateRequest.command === "object" && + threadCreateRequest.command !== null && + "branch" in threadCreateRequest.command + ? threadCreateRequest.command.branch + : null, + ).toBe(worktreeNewBranch ?? "t3code/browser-test"); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect( + wsRequests.findIndex((request) => request._tag === WS_METHODS.gitCreateWorktree), + ).toBeLessThan( + wsRequests.findIndex( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof request.command === "object" && + request.command !== null && + "type" in request.command && + request.command.type === "thread.create", + ), + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps long proposed plans lightweight until the user expands them", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..675f26738 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -46,8 +46,9 @@ import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } fr import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { buildNewThreadDraftDefaults } from "../threadDraftDefaults"; import { readNativeApi } from "../nativeApi"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -398,68 +399,85 @@ export default function Sidebar() { }); }, []); + const resolveNewThreadDefaults = useCallback( + async ( + projectId: ProjectId, + options?: { + projectCwd?: string | null; + preferNewWorktree?: boolean; + forceLocal?: boolean; + }, + ) => { + try { + const api = readNativeApi(); + if (!api) { + throw new Error("Native API not found."); + } + return await buildNewThreadDraftDefaults({ + api, + projectCwd: options?.projectCwd ?? projectCwdById.get(projectId) ?? null, + preferNewWorktree: options?.preferNewWorktree ?? appSettings.newThreadUsesNewWorktree, + ...(options?.forceLocal !== undefined ? { forceLocal: options.forceLocal } : {}), + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to start new thread", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + return null; + } + }, + [appSettings.newThreadUsesNewWorktree, projectCwdById], + ); + const handleNewThread = useCallback( - ( + async ( projectId: ProjectId, options?: { - branch?: string | null; - worktreePath?: string | null; - envMode?: DraftThreadEnvMode; + projectCwd?: string | null; + preferNewWorktree?: boolean; + forceLocal?: boolean; }, ): Promise => { - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; + const draftDefaults = await resolveNewThreadDefaults(projectId, options); + if (!draftDefaults) { + return; + } + const storedDraftThread = getDraftThreadByProjectId(projectId); if (storedDraftThread) { - return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); - if (routeThreadId === storedDraftThread.threadId) { - return; - } - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, - }); - })(); + setDraftThreadContext(storedDraftThread.threadId, draftDefaults); + setProjectDraftThreadId(projectId, storedDraftThread.threadId); + if (routeThreadId === storedDraftThread.threadId) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: storedDraftThread.threadId }, + }); + return; } clearProjectDraftThreadId(projectId); const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } + setDraftThreadContext(routeThreadId, draftDefaults); setProjectDraftThreadId(projectId, routeThreadId); - return Promise.resolve(); + return; } const threadId = newThreadId(); const createdAt = new Date().toISOString(); - return (async () => { - setProjectDraftThreadId(projectId, threadId, { - createdAt, - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", - runtimeMode: DEFAULT_RUNTIME_MODE, - }); + setProjectDraftThreadId(projectId, threadId, { + createdAt, + ...draftDefaults, + runtimeMode: DEFAULT_RUNTIME_MODE, + }); - await navigate({ - to: "/$threadId", - params: { threadId }, - }); - })(); + await navigate({ + to: "/$threadId", + params: { threadId }, + }); }, [ clearProjectDraftThreadId, @@ -467,6 +485,7 @@ export default function Sidebar() { navigate, getDraftThread, routeThreadId, + resolveNewThreadDefaults, setDraftThreadContext, setProjectDraftThreadId, ], @@ -526,7 +545,7 @@ export default function Sidebar() { defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, createdAt, }); - await handleNewThread(projectId).catch(() => undefined); + await handleNewThread(projectId, { projectCwd: cwd }).catch(() => undefined); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; @@ -1053,7 +1072,7 @@ export default function Sidebar() { activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; if (!projectId) return; event.preventDefault(); - void handleNewThread(projectId); + void handleNewThread(projectId, { forceLocal: true }); return; } @@ -1061,11 +1080,7 @@ export default function Sidebar() { const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; if (!projectId) return; event.preventDefault(); - void handleNewThread(projectId, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), - }); + void handleNewThread(projectId); }; const onMouseDown = (event: globalThis.MouseEvent) => { @@ -1153,9 +1168,7 @@ export default function Sidebar() { ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = useMemo( - () => - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"), + () => shortcutLabelForCommand(keybindings, "chat.new"), [keybindings], ); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 93e074442..7c3a556d9 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -304,6 +304,61 @@ function SettingsRouteView() { +
+
+

Threads

+

+ Choose how new draft threads start before you send the first message. +

+
+ +
+ {[ + { + value: false, + title: "Local", + description: "Start new threads in the main project working tree.", + }, + { + value: true, + title: "New worktree", + description: + "Start new threads in worktree mode so the first send creates a new worktree.", + }, + ].map((option) => { + const selected = settings.newThreadUsesNewWorktree === option.value; + return ( + + ); + })} +
+
+

Models

diff --git a/apps/web/src/threadDraftDefaults.test.ts b/apps/web/src/threadDraftDefaults.test.ts new file mode 100644 index 000000000..3e7f4abe7 --- /dev/null +++ b/apps/web/src/threadDraftDefaults.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; + +import { buildNewThreadDraftDefaults } from "./threadDraftDefaults"; + +function makeApi(status: (input: { cwd: string }) => Promise<{ branch: string | null }>) { + return { + git: { + status, + }, + }; +} + +describe("buildNewThreadDraftDefaults", () => { + it("returns a blank local draft when new-worktree preference is off", async () => { + const status = vi.fn(async (_input: { cwd: string }) => ({ branch: null })); + + await expect( + buildNewThreadDraftDefaults({ + api: makeApi(status), + projectCwd: "/repo/project", + preferNewWorktree: false, + }), + ).resolves.toEqual({ + branch: null, + worktreePath: null, + envMode: "local", + }); + + expect(status).not.toHaveBeenCalled(); + }); + + it("returns a blank local draft when forceLocal is enabled", async () => { + const status = vi.fn(async (_input: { cwd: string }) => ({ branch: null })); + + await expect( + buildNewThreadDraftDefaults({ + api: makeApi(status), + projectCwd: "/repo/project", + preferNewWorktree: true, + forceLocal: true, + }), + ).resolves.toEqual({ + branch: null, + worktreePath: null, + envMode: "local", + }); + + expect(status).not.toHaveBeenCalled(); + }); + + it("returns a pending worktree draft seeded from the current branch", async () => { + const status = vi.fn(async (_input: { cwd: string }) => ({ branch: "main" })); + + await expect( + buildNewThreadDraftDefaults({ + api: makeApi(status), + projectCwd: "/repo/project", + preferNewWorktree: true, + }), + ).resolves.toEqual({ + branch: "main", + worktreePath: null, + envMode: "worktree", + }); + + expect(status).toHaveBeenCalledWith({ cwd: "/repo/project" }); + }); + + it("keeps worktree mode even when git status has no current branch", async () => { + const status = vi.fn(async (_input: { cwd: string }) => ({ branch: null })); + + await expect( + buildNewThreadDraftDefaults({ + api: makeApi(status), + projectCwd: "/repo/project", + preferNewWorktree: true, + }), + ).resolves.toEqual({ + branch: null, + worktreePath: null, + envMode: "worktree", + }); + }); +}); diff --git a/apps/web/src/threadDraftDefaults.ts b/apps/web/src/threadDraftDefaults.ts new file mode 100644 index 000000000..fb0e0c8ef --- /dev/null +++ b/apps/web/src/threadDraftDefaults.ts @@ -0,0 +1,43 @@ +import type { DraftThreadEnvMode } from "./composerDraftStore"; + +export interface NewThreadDraftDefaults { + branch: string | null; + worktreePath: null; + envMode: DraftThreadEnvMode; +} + +interface ThreadDraftDefaultsApi { + git: { + status: (input: { cwd: string }) => Promise<{ branch: string | null }>; + }; +} + +export async function buildNewThreadDraftDefaults(input: { + api: ThreadDraftDefaultsApi; + projectCwd: string | null; + preferNewWorktree: boolean; + forceLocal?: boolean; +}): Promise { + if (input.forceLocal === true || input.preferNewWorktree === false) { + return { + branch: null, + worktreePath: null, + envMode: "local", + }; + } + + if (!input.projectCwd) { + return { + branch: null, + worktreePath: null, + envMode: "worktree", + }; + } + + const status = await input.api.git.status({ cwd: input.projectCwd }); + return { + branch: status.branch ?? null, + worktreePath: null, + envMode: "worktree", + }; +} From b145a27b3246ef6f295d96363b3fec91dddb1029 Mon Sep 17 00:00:00 2001 From: Kainoa Newton Date: Thu, 12 Mar 2026 20:29:52 -0700 Subject: [PATCH 2/2] Preserve existing draft thread state when reopening new thread - Stop recomputing defaults when reselecting an existing project draft thread - Keep existing draft branch/mode context instead of resetting it in Sidebar flow - Fallback to worktree mode with null branch when `git.status` fails - Add tests for reopening existing drafts and git status failure handling --- apps/web/src/components/ChatView.browser.tsx | 44 ++++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 15 +++---- apps/web/src/threadDraftDefaults.test.ts | 18 ++++++++ apps/web/src/threadDraftDefaults.ts | 10 ++++- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6da13e01c..f7f7e3504 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1069,6 +1069,50 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("reopens an existing draft thread without resetting its branch or mode", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/existing-draft", + worktreePath: null, + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + await newThreadButton.click(); + + await waitForURL( + mounted.router, + (path) => path === `/${THREAD_ID}`, + "Existing draft thread should remain selected when reopening it.", + ); + + expect(useComposerDraftStore.getState().getDraftThread(THREAD_ID)).toMatchObject({ + projectId: PROJECT_ID, + branch: "feature/existing-draft", + worktreePath: null, + envMode: "worktree", + }); + } finally { + await mounted.cleanup(); + } + }); + it("creates the worktree on first send for a pending worktree draft", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 675f26738..8b877cf0c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -267,7 +267,6 @@ export default function Sidebar() { const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -440,14 +439,8 @@ export default function Sidebar() { forceLocal?: boolean; }, ): Promise => { - const draftDefaults = await resolveNewThreadDefaults(projectId, options); - if (!draftDefaults) { - return; - } - const storedDraftThread = getDraftThreadByProjectId(projectId); if (storedDraftThread) { - setDraftThreadContext(storedDraftThread.threadId, draftDefaults); setProjectDraftThreadId(projectId, storedDraftThread.threadId); if (routeThreadId === storedDraftThread.threadId) { return; @@ -462,10 +455,15 @@ export default function Sidebar() { const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { - setDraftThreadContext(routeThreadId, draftDefaults); setProjectDraftThreadId(projectId, routeThreadId); return; } + + const draftDefaults = await resolveNewThreadDefaults(projectId, options); + if (!draftDefaults) { + return; + } + const threadId = newThreadId(); const createdAt = new Date().toISOString(); setProjectDraftThreadId(projectId, threadId, { @@ -486,7 +484,6 @@ export default function Sidebar() { getDraftThread, routeThreadId, resolveNewThreadDefaults, - setDraftThreadContext, setProjectDraftThreadId, ], ); diff --git a/apps/web/src/threadDraftDefaults.test.ts b/apps/web/src/threadDraftDefaults.test.ts index 3e7f4abe7..88b7f6a0a 100644 --- a/apps/web/src/threadDraftDefaults.test.ts +++ b/apps/web/src/threadDraftDefaults.test.ts @@ -81,4 +81,22 @@ describe("buildNewThreadDraftDefaults", () => { envMode: "worktree", }); }); + + it("keeps worktree mode when git status fails", async () => { + const status = vi.fn(async (_input: { cwd: string }) => { + throw new Error("git status failed"); + }); + + await expect( + buildNewThreadDraftDefaults({ + api: makeApi(status), + projectCwd: "/repo/project", + preferNewWorktree: true, + }), + ).resolves.toEqual({ + branch: null, + worktreePath: null, + envMode: "worktree", + }); + }); }); diff --git a/apps/web/src/threadDraftDefaults.ts b/apps/web/src/threadDraftDefaults.ts index fb0e0c8ef..be655d2c4 100644 --- a/apps/web/src/threadDraftDefaults.ts +++ b/apps/web/src/threadDraftDefaults.ts @@ -34,9 +34,15 @@ export async function buildNewThreadDraftDefaults(input: { }; } - const status = await input.api.git.status({ cwd: input.projectCwd }); + let status: { branch: string | null } | null = null; + try { + status = await input.api.git.status({ cwd: input.projectCwd }); + } catch { + status = null; + } + return { - branch: status.branch ?? null, + branch: status?.branch ?? null, worktreePath: null, envMode: "worktree", };