diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 001cebba1..0c00fed4e 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -51,7 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state -- `chat.newLocal`: create a new local chat thread for the active project (no worktree context) +- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index ac30989cb..18e76d2f9 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -21,6 +21,9 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( + Schema.withConstructorDefault(() => Option.some("local")), + ), confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f35f87826..a8f84d564 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasUnseenCompletion, + resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -63,6 +64,25 @@ describe("shouldClearThreadSelectionOnMouseDown", () => { }); }); +describe("resolveSidebarNewThreadEnvMode", () => { + it("uses the app default when the caller does not request a specific mode", () => { + expect( + resolveSidebarNewThreadEnvMode({ + defaultEnvMode: "worktree", + }), + ).toBe("worktree"); + }); + + it("preserves an explicit requested mode over the app default", () => { + expect( + resolveSidebarNewThreadEnvMode({ + requestedEnvMode: "local", + defaultEnvMode: "worktree", + }), + ).toBe("local"); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8acbed63a..c65ef8379 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -3,6 +3,7 @@ import { cn } from "../lib/utils"; import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +export type SidebarNewThreadEnvMode = "local" | "worktree"; export interface ThreadStatusPill { label: @@ -38,6 +39,13 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +export function resolveSidebarNewThreadEnvMode(input: { + requestedEnvMode?: SidebarNewThreadEnvMode; + defaultEnvMode: SidebarNewThreadEnvMode; +}): SidebarNewThreadEnvMode { + return input.requestedEnvMode ?? input.defaultEnvMode; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ffd6de92..61cb888f3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -440,7 +441,9 @@ export default function Sidebar() { defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, createdAt, }); - await handleNewThread(projectId).catch(() => undefined); + await handleNewThread(projectId, { + envMode: appSettings.defaultThreadEnvMode, + }).catch(() => undefined); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; @@ -464,6 +467,7 @@ export default function Sidebar() { isAddingProject, projects, shouldBrowseForProjectImmediately, + appSettings.defaultThreadEnvMode, ], ); @@ -1355,7 +1359,11 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void handleNewThread(project.id); + void handleNewThread(project.id, { + envMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: appSettings.defaultThreadEnvMode, + }), + }); }} > diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index 135e0fc30..4b65b5466 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -8,7 +8,7 @@ function Switch({ className, ...props }: SwitchPrimitive.Root.Props) { return ( +
+
+

Threads

+

+ Choose the default workspace mode for newly created draft threads. +

+
+ +
+
+

Default to New worktree

+

+ New threads start in New worktree mode instead of Local. +

+
+ + updateSettings({ + defaultThreadEnvMode: checked ? "worktree" : "local", + }) + } + aria-label="Default new threads to New worktree mode" + /> +
+ + {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( +
+ +
+ ) : null} +
+

Responses

diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 8e3145d99..6014bc5f2 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -12,6 +12,8 @@ import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; +import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; +import { useAppSettings } from "~/appSettings"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -27,6 +29,7 @@ function ChatRouteGlobalShortcuts() { ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen : false, ); + const { settings: appSettings } = useAppSettings(); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -51,7 +54,11 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId); + void handleNewThread(projectId, { + envMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: appSettings.defaultThreadEnvMode, + }), + }); return; } @@ -78,6 +85,7 @@ function ChatRouteGlobalShortcuts() { projects, selectedThreadIdsSize, terminalOpen, + appSettings.defaultThreadEnvMode, ]); return null;