| author | |
| committer | |
| log | d0ae0adc9923e51f7027ee16afedb14b3ae7779c |
| tree | 5a00773578051e81afeb449006fd6e4354e6d820 |
| parent | 995a26660edbce7cca15da812972c23151a35f3d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 state | 53 | - `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 editor | 55 | - `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`) |
| 57 | 57 |
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"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 65 | 66 | ||
| 67 | describe("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 | |||
| 66 | describe("resolveThreadStatusPill", () => { | 86 | describe("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"; |
| 3 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; | 3 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; |
| 4 | 4 | ||
| 5 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | 5 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 6 | export type SidebarNewThreadEnvMode = "local" | "worktree"; | ||
| 6 | 7 | ||
| 7 | export interface ThreadStatusPill { | 8 | export 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 | } |
| 40 | 41 | ||
| 42 | export function resolveSidebarNewThreadEnvMode(input: { | ||
| 43 | requestedEnvMode?: SidebarNewThreadEnvMode; | ||
| 44 | defaultEnvMode: SidebarNewThreadEnvMode; | ||
| 45 | }): SidebarNewThreadEnvMode { | ||
| 46 | return input.requestedEnvMode ?? input.defaultEnvMode; | ||
| 47 | } | ||
| 48 | |||
| 41 | export function resolveThreadRowClassName(input: { | 49 | export 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"; |
| 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 86 | import { | 86 | import { |
| 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 | ); |
| 469 | 473 | ||
| ... | @@ -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.Root | 9 | <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> |
| 504 | 504 | ||
| 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"; |
| 12 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 12 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 13 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 13 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 14 | import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; | 14 | import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; |
| 15 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; | ||
| 16 | import { useAppSettings } from "~/appSettings"; | ||
| 15 | 17 | ||
| 16 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 18 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 17 | 19 | ||
| ... | @@ -27,6 +29,7 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -27,6 +29,7 @@ function ChatRouteGlobalShortcuts() { |
| 27 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen | 29 | ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen |
| 28 | : false, | 30 | : false, |
| 29 | ); | 31 | ); |
| 32 | const { settings: appSettings } = useAppSettings(); | ||
| 30 | 33 | ||
| 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 | } |
| 57 | 64 | ||
| ... | @@ -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 | ]); |
| 82 | 90 | ||
| 83 | return null; | 91 | return null; |