authorgravatar for crazywolf132@gmail.com0x1f99d <crazywolf132@gmail.com> 2026-03-13 14:18:20+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 20:18:20-07:00
logd0ae0adc9923e51f7027ee16afedb14b3ae7779c
tree5a00773578051e81afeb449006fd6e4354e6d820
parent995a26660edbce7cca15da812972c23151a35f3d
signature Signed by PGP key B5690EEEBB952194

fix(web): add default thread env mode setting (#892)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

8 files changed, 95 insertions(+), 5 deletions(-)

KEYBINDINGS.md+1-1
...@@ -51,7 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged...@@ -51,7 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
51- `terminal.new`: create new terminal (in focused terminal context by default)51- `terminal.new`: create new terminal (in focused terminal context by default)
52- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)52- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
53- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state53- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
54- `chat.newLocal`: create a new local chat thread for the active project (no worktree context)54- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
55- `editor.openFavorite`: open current project/worktree in the last-used editor55- `editor.openFavorite`: open current project/worktree in the last-used editor
56- `script.{id}.run`: run a project script by id (for example `script.test.run`)56- `script.{id}.run`: run a project script by id (for example `script.test.run`)
5757
apps/web/src/appSettings.ts+3
...@@ -21,6 +21,9 @@ const AppSettingsSchema = Schema.Struct({...@@ -21,6 +21,9 @@ const AppSettingsSchema = Schema.Struct({
21 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(21 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
22 Schema.withConstructorDefault(() => Option.some("")),22 Schema.withConstructorDefault(() => Option.some("")),
23 ),23 ),
24 defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe(
25 Schema.withConstructorDefault(() => Option.some("local")),
26 ),
24 confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),27 confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
25 enableAssistantStreaming: Schema.Boolean.pipe(28 enableAssistantStreaming: Schema.Boolean.pipe(
26 Schema.withConstructorDefault(() => Option.some(false)),29 Schema.withConstructorDefault(() => Option.some(false)),
apps/web/src/components/Sidebar.logic.test.ts+20
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
3import {3import {
4 hasUnseenCompletion,4 hasUnseenCompletion,
5 resolveSidebarNewThreadEnvMode,
5 resolveThreadRowClassName,6 resolveThreadRowClassName,
6 resolveThreadStatusPill,7 resolveThreadStatusPill,
7 shouldClearThreadSelectionOnMouseDown,8 shouldClearThreadSelectionOnMouseDown,
...@@ -63,6 +64,25 @@ describe("shouldClearThreadSelectionOnMouseDown", () => {...@@ -63,6 +64,25 @@ describe("shouldClearThreadSelectionOnMouseDown", () => {
63 });64 });
64});65});
6566
67describe("resolveSidebarNewThreadEnvMode", () => {
68 it("uses the app default when the caller does not request a specific mode", () => {
69 expect(
70 resolveSidebarNewThreadEnvMode({
71 defaultEnvMode: "worktree",
72 }),
73 ).toBe("worktree");
74 });
75
76 it("preserves an explicit requested mode over the app default", () => {
77 expect(
78 resolveSidebarNewThreadEnvMode({
79 requestedEnvMode: "local",
80 defaultEnvMode: "worktree",
81 }),
82 ).toBe("local");
83 });
84});
85
66describe("resolveThreadStatusPill", () => {86describe("resolveThreadStatusPill", () => {
67 const baseThread = {87 const baseThread = {
68 interactionMode: "plan" as const,88 interactionMode: "plan" as const,
apps/web/src/components/Sidebar.logic.ts+8
...@@ -3,6 +3,7 @@ import { cn } from "../lib/utils";...@@ -3,6 +3,7 @@ import { cn } from "../lib/utils";
3import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";3import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
44
5export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";5export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
6export type SidebarNewThreadEnvMode = "local" | "worktree";
67
7export interface ThreadStatusPill {8export interface ThreadStatusPill {
8 label:9 label:
...@@ -38,6 +39,13 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null...@@ -38,6 +39,13 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
38 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);39 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
39}40}
4041
42export function resolveSidebarNewThreadEnvMode(input: {
43 requestedEnvMode?: SidebarNewThreadEnvMode;
44 defaultEnvMode: SidebarNewThreadEnvMode;
45}): SidebarNewThreadEnvMode {
46 return input.requestedEnvMode ?? input.defaultEnvMode;
47}
48
41export function resolveThreadRowClassName(input: {49export function resolveThreadRowClassName(input: {
42 isActive: boolean;50 isActive: boolean;
43 isSelected: boolean;51 isSelected: boolean;
apps/web/src/components/Sidebar.tsx+10-2
...@@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";...@@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
85import { isNonEmpty as isNonEmptyString } from "effect/String";85import { isNonEmpty as isNonEmptyString } from "effect/String";
86import {86import {
87 resolveSidebarNewThreadEnvMode,
87 resolveThreadRowClassName,88 resolveThreadRowClassName,
88 resolveThreadStatusPill,89 resolveThreadStatusPill,
89 shouldClearThreadSelectionOnMouseDown,90 shouldClearThreadSelectionOnMouseDown,
...@@ -440,7 +441,9 @@ export default function Sidebar() {...@@ -440,7 +441,9 @@ export default function Sidebar() {
440 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,441 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
441 createdAt,442 createdAt,
442 });443 });
443 await handleNewThread(projectId).catch(() => undefined);444 await handleNewThread(projectId, {
445 envMode: appSettings.defaultThreadEnvMode,
446 }).catch(() => undefined);
444 } catch (error) {447 } catch (error) {
445 const description =448 const description =
446 error instanceof Error ? error.message : "An error occurred while adding the project.";449 error instanceof Error ? error.message : "An error occurred while adding the project.";
...@@ -464,6 +467,7 @@ export default function Sidebar() {...@@ -464,6 +467,7 @@ export default function Sidebar() {
464 isAddingProject,467 isAddingProject,
465 projects,468 projects,
466 shouldBrowseForProjectImmediately,469 shouldBrowseForProjectImmediately,
470 appSettings.defaultThreadEnvMode,
467 ],471 ],
468 );472 );
469473
...@@ -1355,7 +1359,11 @@ export default function Sidebar() {...@@ -1355,7 +1359,11 @@ export default function Sidebar() {
1355 onClick={(event) => {1359 onClick={(event) => {
1356 event.preventDefault();1360 event.preventDefault();
1357 event.stopPropagation();1361 event.stopPropagation();
1358 void handleNewThread(project.id);1362 void handleNewThread(project.id, {
1363 envMode: resolveSidebarNewThreadEnvMode({
1364 defaultEnvMode: appSettings.defaultThreadEnvMode,
1365 }),
1366 });
1359 }}1367 }}
1360 >1368 >
1361 <SquarePenIcon className="size-3.5" />1369 <SquarePenIcon className="size-3.5" />
apps/web/src/components/ui/switch.tsx+1-1
...@@ -8,7 +8,7 @@ function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {...@@ -8,7 +8,7 @@ function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
8 return (8 return (
9 <SwitchPrimitive.Root9 <SwitchPrimitive.Root
10 className={cn(10 className={cn(
11 "inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 items-center rounded-full p-px outline-none transition-[background-color,box-shadow] duration-200 [--thumb-size:--spacing(5)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-64 sm:[--thumb-size:--spacing(4)]",11 "inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 cursor-pointer items-center rounded-full p-px outline-none transition-[background-color,box-shadow] duration-200 [--thumb-size:--spacing(5)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:cursor-not-allowed data-disabled:opacity-64 sm:[--thumb-size:--spacing(4)]",
12 className,12 className,
13 )}13 )}
14 data-slot="switch"14 data-slot="switch"
apps/web/src/routes/_chat.settings.tsx+43
...@@ -502,6 +502,49 @@ function SettingsRouteView() {...@@ -502,6 +502,49 @@ function SettingsRouteView() {
502 </div>502 </div>
503 </section>503 </section>
504504
505 <section className="rounded-2xl border border-border bg-card p-5">
506 <div className="mb-4">
507 <h2 className="text-sm font-medium text-foreground">Threads</h2>
508 <p className="mt-1 text-xs text-muted-foreground">
509 Choose the default workspace mode for newly created draft threads.
510 </p>
511 </div>
512
513 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
514 <div>
515 <p className="text-sm font-medium text-foreground">Default to New worktree</p>
516 <p className="text-xs text-muted-foreground">
517 New threads start in New worktree mode instead of Local.
518 </p>
519 </div>
520 <Switch
521 checked={settings.defaultThreadEnvMode === "worktree"}
522 onCheckedChange={(checked) =>
523 updateSettings({
524 defaultThreadEnvMode: checked ? "worktree" : "local",
525 })
526 }
527 aria-label="Default new threads to New worktree mode"
528 />
529 </div>
530
531 {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? (
532 <div className="mt-3 flex justify-end">
533 <Button
534 size="xs"
535 variant="outline"
536 onClick={() =>
537 updateSettings({
538 defaultThreadEnvMode: defaults.defaultThreadEnvMode,
539 })
540 }
541 >
542 Restore default
543 </Button>
544 </div>
545 ) : null}
546 </section>
547
505 <section className="rounded-2xl border border-border bg-card p-5">548 <section className="rounded-2xl border border-border bg-card p-5">
506 <div className="mb-4">549 <div className="mb-4">
507 <h2 className="text-sm font-medium text-foreground">Responses</h2>550 <h2 className="text-sm font-medium text-foreground">Responses</h2>
apps/web/src/routes/_chat.tsx+9-1
...@@ -12,6 +12,8 @@ import { resolveShortcutCommand } from "../keybindings";...@@ -12,6 +12,8 @@ import { resolveShortcutCommand } from "../keybindings";
12import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";12import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
13import { useThreadSelectionStore } from "../threadSelectionStore";13import { useThreadSelectionStore } from "../threadSelectionStore";
14import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";14import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";
15import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
16import { useAppSettings } from "~/appSettings";
1517
16const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];18const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
1719
...@@ -27,6 +29,7 @@ function ChatRouteGlobalShortcuts() {...@@ -27,6 +29,7 @@ function ChatRouteGlobalShortcuts() {
27 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen29 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
28 : false,30 : false,
29 );31 );
32 const { settings: appSettings } = useAppSettings();
3033
31 useEffect(() => {34 useEffect(() => {
32 const onWindowKeyDown = (event: KeyboardEvent) => {35 const onWindowKeyDown = (event: KeyboardEvent) => {
...@@ -51,7 +54,11 @@ function ChatRouteGlobalShortcuts() {...@@ -51,7 +54,11 @@ function ChatRouteGlobalShortcuts() {
51 if (command === "chat.newLocal") {54 if (command === "chat.newLocal") {
52 event.preventDefault();55 event.preventDefault();
53 event.stopPropagation();56 event.stopPropagation();
54 void handleNewThread(projectId);57 void handleNewThread(projectId, {
58 envMode: resolveSidebarNewThreadEnvMode({
59 defaultEnvMode: appSettings.defaultThreadEnvMode,
60 }),
61 });
55 return;62 return;
56 }63 }
5764
...@@ -78,6 +85,7 @@ function ChatRouteGlobalShortcuts() {...@@ -78,6 +85,7 @@ function ChatRouteGlobalShortcuts() {
78 projects,85 projects,
79 selectedThreadIdsSize,86 selectedThreadIdsSize,
80 terminalOpen,87 terminalOpen,
88 appSettings.defaultThreadEnvMode,
81 ]);89 ]);
8290
83 return null;91 return null;