From 73db38a281ba7f0d02567cc6b25264f69526fa50 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 19:40:49 +1100 Subject: [PATCH 1/4] Add default thread env mode setting --- apps/web/src/appSettings.test.ts | 78 +++++++++++++++++++++++++- apps/web/src/appSettings.ts | 3 + apps/web/src/components/Sidebar.tsx | 3 +- apps/web/src/routes/_chat.settings.tsx | 43 ++++++++++++++ 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 213e4cd3d4..f9b8189a1f 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,12 +1,55 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + getAppSettingsSnapshot, getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, } from "./appSettings"; +function createStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + clear: () => { + values.clear(); + }, + }; +} + +function writeSettings(partial: Record) { + localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify({ + codexBinaryPath: "", + codexHomePath: "", + confirmThreadDelete: true, + enableAssistantStreaming: false, + customCodexModels: [], + ...partial, + }), + ); +} + +beforeEach(() => { + const storage = createStorage(); + vi.stubGlobal("localStorage", storage); + vi.stubGlobal("window", { + localStorage: storage, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { expect( @@ -72,3 +115,36 @@ describe("getSlashModelOptions", () => { expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); }); + +describe("getAppSettingsSnapshot", () => { + it("defaults the thread environment mode to local for older persisted settings", () => { + localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify({ + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "", + confirmThreadDelete: true, + enableAssistantStreaming: false, + customCodexModels: [], + }), + ); + + expect(getAppSettingsSnapshot().defaultThreadEnvMode).toBe("local"); + }); + + it("falls back to local when the persisted thread environment mode is invalid", () => { + writeSettings({ + defaultThreadEnvMode: "invalid", + }); + + expect(getAppSettingsSnapshot().defaultThreadEnvMode).toBe("local"); + }); + + it("reads a persisted worktree default for new threads", () => { + writeSettings({ + defaultThreadEnvMode: "worktree", + }); + + expect(getAppSettingsSnapshot().defaultThreadEnvMode).toBe("worktree"); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb25..d960969758 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -17,6 +17,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.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b804..8235efe7ce 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -451,7 +451,7 @@ export default function Sidebar() { createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", + envMode: options?.envMode ?? appSettings.defaultThreadEnvMode, runtimeMode: DEFAULT_RUNTIME_MODE, }); @@ -469,6 +469,7 @@ export default function Sidebar() { routeThreadId, setDraftThreadContext, setProjectDraftThreadId, + appSettings.defaultThreadEnvMode, ], ); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 93e0744421..b1422d8be5 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -437,6 +437,49 @@ function SettingsRouteView() { +
+
+

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

From 000ce3a0cb230b2548b155346833897702999beb Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 20:01:20 +1100 Subject: [PATCH 2/4] fix(web): apply default env mode to reused drafts --- apps/web/src/components/Sidebar.logic.test.ts | 20 +++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 8 +++++++ apps/web/src/components/Sidebar.tsx | 22 +++++++++++++++---- apps/web/src/components/ui/switch.tsx | 2 +- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 634d91e9dc..fbde2aa171 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, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -62,6 +63,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 f1afa0f646..cdd6b13ee1 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -2,6 +2,7 @@ import type { Thread } from "../types"; 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: @@ -37,6 +38,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 resolveThreadStatusPill(input: { thread: ThreadStatusInput; hasPendingApprovals: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8235efe7ce..b4d09f0865 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -83,7 +83,11 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { + resolveSidebarNewThreadEnvMode, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -310,6 +314,7 @@ export default function Sidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const shouldBrowseForProjectImmediately = isElectron; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const defaultNewThreadEnvMode = appSettings.defaultThreadEnvMode; const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -527,7 +532,9 @@ export default function Sidebar() { defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, createdAt, }); - await handleNewThread(projectId).catch(() => undefined); + await handleNewThread(projectId, { + envMode: defaultNewThreadEnvMode, + }).catch(() => undefined); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; @@ -546,6 +553,7 @@ export default function Sidebar() { finishAddingProject(); }, [ + defaultNewThreadEnvMode, focusMostRecentThreadForProject, handleNewThread, isAddingProject, @@ -1054,7 +1062,9 @@ export default function Sidebar() { activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; if (!projectId) return; event.preventDefault(); - void handleNewThread(projectId); + void handleNewThread(projectId, { + envMode: "local", + }); return; } @@ -1486,7 +1496,11 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void handleNewThread(project.id); + void handleNewThread(project.id, { + envMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: defaultNewThreadEnvMode, + }), + }); }} > diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index 135e0fc30f..4b65b5466b 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 ( Date: Thu, 12 Mar 2026 19:51:45 -0700 Subject: [PATCH 3/4] rm --- apps/web/src/appSettings.test.ts | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 37d5df2d28..326bceaacf 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { DEFAULT_TIMESTAMP_FORMAT, @@ -7,34 +7,6 @@ import { resolveAppModelSelection, } from "./appSettings"; -function createStorage() { - const values = new Map(); - return { - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => { - values.set(key, value); - }, - removeItem: (key: string) => { - values.delete(key); - }, - clear: () => { - values.clear(); - }, - }; -} - -beforeEach(() => { - const storage = createStorage(); - vi.stubGlobal("localStorage", storage); - vi.stubGlobal("window", { - localStorage: storage, - }); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { expect( From 3ec278c51986795a544ac4f82100343d64f88bf8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 19:56:09 -0700 Subject: [PATCH 4/4] use for chat.newLocal command as well --- KEYBINDINGS.md | 2 +- apps/web/src/routes/_chat.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 001cebba1a..0c00fed4e7 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/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 8e3145d999..6014bc5f23 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;