From 73db38a281ba7f0d02567cc6b25264f69526fa50 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 19:40:49 +1100 Subject: [PATCH 1/2] 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 213e4cd3d..f9b8189a1 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 5ed218fb2..d96096975 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 8b68c3b80..8235efe7c 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 93e074442..b1422d8be 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/2] 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 634d91e9d..fbde2aa17 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 f1afa0f64..cdd6b13ee 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 8235efe7c..b4d09f086 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 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 (