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
5151- `terminal.new`: create new terminal (in focused terminal context by default)
5252- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
5353- `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`))
5555- `editor.openFavorite`: open current project/worktree in the last-used editor
5656- `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({
2121 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
2222 Schema.withConstructorDefault(() => Option.some("")),
2323 ),
24 defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe(
25 Schema.withConstructorDefault(() => Option.some("local")),
26 ),
2427 confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
2528 enableAssistantStreaming: Schema.Boolean.pipe(
2629 Schema.withConstructorDefault(() => Option.some(false)),
apps/web/src/components/Sidebar.logic.test.ts+20
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
33import {
44 hasUnseenCompletion,
5 resolveSidebarNewThreadEnvMode,
56 resolveThreadRowClassName,
67 resolveThreadStatusPill,
78 shouldClearThreadSelectionOnMouseDown,
......@@ -63,6 +64,25 @@ describe("shouldClearThreadSelectionOnMouseDown", () => {
6364 });
6465});
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
6686describe("resolveThreadStatusPill", () => {
6787 const baseThread = {
6888 interactionMode: "plan" as const,
apps/web/src/components/Sidebar.logic.ts+8
......@@ -3,6 +3,7 @@ import { cn } from "../lib/utils";
33import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
44
55export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
6export type SidebarNewThreadEnvMode = "local" | "worktree";
67
78export interface ThreadStatusPill {
89 label:
......@@ -38,6 +39,13 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
3839 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
3940}
4041
42export function resolveSidebarNewThreadEnvMode(input: {
43 requestedEnvMode?: SidebarNewThreadEnvMode;
44 defaultEnvMode: SidebarNewThreadEnvMode;
45}): SidebarNewThreadEnvMode {
46 return input.requestedEnvMode ?? input.defaultEnvMode;
47}
48
4149export function resolveThreadRowClassName(input: {
4250 isActive: boolean;
4351 isSelected: boolean;
apps/web/src/components/Sidebar.tsx+10-2
......@@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
8484import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
8585import { isNonEmpty as isNonEmptyString } from "effect/String";
8686import {
87 resolveSidebarNewThreadEnvMode,
8788 resolveThreadRowClassName,
8889 resolveThreadStatusPill,
8990 shouldClearThreadSelectionOnMouseDown,
......@@ -440,7 +441,9 @@ export default function Sidebar() {
440441 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
441442 createdAt,
442443 });
443 await handleNewThread(projectId).catch(() => undefined);
444 await handleNewThread(projectId, {
445 envMode: appSettings.defaultThreadEnvMode,
446 }).catch(() => undefined);
444447 } catch (error) {
445448 const description =
446449 error instanceof Error ? error.message : "An error occurred while adding the project.";
......@@ -464,6 +467,7 @@ export default function Sidebar() {
464467 isAddingProject,
465468 projects,
466469 shouldBrowseForProjectImmediately,
470 appSettings.defaultThreadEnvMode,
467471 ],
468472 );
469473
......@@ -1355,7 +1359,11 @@ export default function Sidebar() {
13551359 onClick={(event) => {
13561360 event.preventDefault();
13571361 event.stopPropagation();
1358 void handleNewThread(project.id);
1362 void handleNewThread(project.id, {
1363 envMode: resolveSidebarNewThreadEnvMode({
1364 defaultEnvMode: appSettings.defaultThreadEnvMode,
1365 }),
1366 });
13591367 }}
13601368 >
13611369 <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) {
88 return (
99 <SwitchPrimitive.Root
1010 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)]",
1212 className,
1313 )}
1414 data-slot="switch"
apps/web/src/routes/_chat.settings.tsx+43
......@@ -502,6 +502,49 @@ function SettingsRouteView() {
502502 </div>
503503 </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
505548 <section className="rounded-2xl border border-border bg-card p-5">
506549 <div className="mb-4">
507550 <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";
1212import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
1313import { useThreadSelectionStore } from "../threadSelectionStore";
1414import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";
15import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
16import { useAppSettings } from "~/appSettings";
1517
1618const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
1719
......@@ -27,6 +29,7 @@ function ChatRouteGlobalShortcuts() {
2729 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
2830 : false,
2931 );
32 const { settings: appSettings } = useAppSettings();
3033
3134 useEffect(() => {
3235 const onWindowKeyDown = (event: KeyboardEvent) => {
......@@ -51,7 +54,11 @@ function ChatRouteGlobalShortcuts() {
5154 if (command === "chat.newLocal") {
5255 event.preventDefault();
5356 event.stopPropagation();
54 void handleNewThread(projectId);
57 void handleNewThread(projectId, {
58 envMode: resolveSidebarNewThreadEnvMode({
59 defaultEnvMode: appSettings.defaultThreadEnvMode,
60 }),
61 });
5562 return;
5663 }
5764
......@@ -78,6 +85,7 @@ function ChatRouteGlobalShortcuts() {
7885 projects,
7986 selectedThreadIdsSize,
8087 terminalOpen,
88 appSettings.defaultThreadEnvMode,
8189 ]);
8290
8391 return null;