From 493ea42525d625364fcf225df6eaf773cd9245d9 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 19:41:03 +1100 Subject: [PATCH 1/3] Make Local/New worktree a real toggle --- .../src/components/BranchToolbar.browser.tsx | 133 ++++++++++++++++++ apps/web/src/components/BranchToolbar.tsx | 56 +++++--- apps/web/vitest.browser.config.ts | 1 + 3 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/BranchToolbar.browser.tsx diff --git a/apps/web/src/components/BranchToolbar.browser.tsx b/apps/web/src/components/BranchToolbar.browser.tsx new file mode 100644 index 0000000000..65d2f0b955 --- /dev/null +++ b/apps/web/src/components/BranchToolbar.browser.tsx @@ -0,0 +1,133 @@ +import "../index.css"; + +import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { useStore } from "../store"; +import BranchToolbar from "./BranchToolbar"; + +vi.mock("./BranchToolbarBranchSelector", () => ({ + BranchToolbarBranchSelector: () =>
, +})); + +const THREAD_ID = ThreadId.makeUnsafe("thread-branch-toolbar"); +const PROJECT_ID = ProjectId.makeUnsafe("project-branch-toolbar"); + +describe("BranchToolbar", () => { + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + useStore.setState({ + projects: [ + { + id: PROJECT_ID, + name: "Project", + cwd: "/repo/project", + model: "gpt-5", + expanded: true, + scripts: [], + }, + ], + threads: [], + threadsHydrated: false, + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders a segmented local/worktree toggle for editable draft threads", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: "2026-03-11T10:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "local", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + const onEnvModeChange = vi.fn<(mode: "local" | "worktree") => void>(); + await render( + , + ); + + const localToggle = page.getByRole("button", { name: "Local" }); + const worktreeToggle = page.getByRole("button", { name: "New worktree" }); + + await expect.element(localToggle).toHaveAttribute("aria-pressed", "true"); + await expect.element(worktreeToggle).toHaveAttribute("aria-pressed", "false"); + + await worktreeToggle.click(); + + expect(onEnvModeChange).toHaveBeenCalledTimes(1); + expect(onEnvModeChange).toHaveBeenCalledWith("worktree"); + }); + + it("keeps the selected mode visible when the environment is locked", async () => { + useStore.setState({ + projects: useStore.getState().projects, + threads: [ + { + id: THREAD_ID, + codexThreadId: null, + projectId: PROJECT_ID, + title: "Locked thread", + model: "gpt-5", + runtimeMode: "full-access", + interactionMode: "default", + session: null, + createdAt: "2026-03-11T10:00:00.000Z", + latestTurn: null, + lastVisitedAt: undefined, + branch: "main", + worktreePath: null, + turnDiffSummaries: [], + activities: [], + proposedPlans: [], + error: null, + messages: [ + { + id: MessageId.makeUnsafe("message-1"), + role: "user", + text: "hello", + streaming: false, + createdAt: "2026-03-11T10:00:00.000Z", + }, + ], + }, + ], + threadsHydrated: false, + }); + + const onEnvModeChange = vi.fn<(mode: "local" | "worktree") => void>(); + await render( + , + ); + + const localToggle = page.getByRole("button", { name: "Local" }); + const worktreeToggle = page.getByRole("button", { name: "New worktree" }); + + await expect.element(localToggle).toBeDisabled(); + await expect.element(worktreeToggle).toBeDisabled(); + await expect.element(localToggle).toHaveAttribute("aria-pressed", "true"); + + expect(onEnvModeChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b2c60b2429..eee55770a4 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -11,7 +11,7 @@ import { resolveEffectiveEnvMode, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; -import { Button } from "./ui/button"; +import { Toggle, ToggleGroup } from "./ui/toggle-group"; interface BranchToolbarProps { threadId: ThreadId; @@ -21,6 +21,9 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; } +const ENV_MODE_TOGGLE_CLASS_NAME = + "rounded-full border-0 px-3 text-xs font-medium text-muted-foreground/70 shadow-none hover:bg-background/80 hover:text-foreground/85 data-[pressed]:bg-background data-[pressed]:text-foreground data-[pressed]:shadow-xs data-disabled:opacity-100 data-disabled:text-muted-foreground/60"; + export default function BranchToolbar({ threadId, onEnvModeChange, @@ -47,6 +50,9 @@ export default function BranchToolbar({ hasServerThread, draftThreadEnvMode: draftThread?.envMode, }); + const envToggleValue = activeWorktreePath ? "worktree" : effectiveEnvMode; + const envToggleDisabled = envLocked || activeWorktreePath !== null; + const worktreeToggleLabel = activeWorktreePath ? "Worktree" : "New worktree"; const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { @@ -103,23 +109,39 @@ export default function BranchToolbar({ if (!activeThreadId || !activeProject) return null; return ( -
-
- {envLocked || activeWorktreePath ? ( - - {activeWorktreePath ? "Worktree" : "Local"} - - ) : ( - - )} + {worktreeToggleLabel} + +
Date: Wed, 11 Mar 2026 19:47:41 +1100 Subject: [PATCH 2/3] style(web): polish workspace mode toggle --- apps/web/src/components/BranchToolbar.tsx | 25 +++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index eee55770a4..14273a2a4f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,4 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; +import { FolderIcon, GitBranchPlusIcon } from "lucide-react"; import { useCallback } from "react"; import { newCommandId } from "../lib/utils"; @@ -21,8 +22,14 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; } +const ENV_MODE_TOGGLE_GROUP_CLASS_NAME = + "rounded-2xl border border-border/70 bg-background/80 p-1 shadow-xs/5 backdrop-blur-sm dark:bg-card/75"; + const ENV_MODE_TOGGLE_CLASS_NAME = - "rounded-full border-0 px-3 text-xs font-medium text-muted-foreground/70 shadow-none hover:bg-background/80 hover:text-foreground/85 data-[pressed]:bg-background data-[pressed]:text-foreground data-[pressed]:shadow-xs data-disabled:opacity-100 data-disabled:text-muted-foreground/60"; + "gap-2 rounded-xl border border-transparent px-3 text-xs font-medium text-muted-foreground/75 shadow-none transition-all duration-150 hover:bg-background/70 hover:text-foreground/85 data-[pressed]:border-border/80 data-[pressed]:bg-primary/10 data-[pressed]:text-foreground data-[pressed]:ring-1 data-[pressed]:ring-primary/10 data-[pressed]:shadow-sm data-disabled:opacity-100 data-disabled:text-muted-foreground/60 [&_[data-slot=icon-chip]]:bg-background/70 [&_[data-slot=icon-chip]]:ring-1 [&_[data-slot=icon-chip]]:ring-border/40 data-[pressed]:[&_[data-slot=icon-chip]]:bg-primary/12 data-[pressed]:[&_[data-slot=icon-chip]]:ring-primary/20 [&_svg]:size-3.5 [&_svg]:text-muted-foreground/55 [&_svg]:transition-colors data-[pressed]:[&_svg]:text-primary data-disabled:[&_svg]:text-muted-foreground/35"; + +const ENV_MODE_ICON_CHIP_CLASS_NAME = + "flex size-4 shrink-0 items-center justify-center rounded-full transition-colors"; export default function BranchToolbar({ threadId, @@ -113,7 +120,7 @@ export default function BranchToolbar({
+ Local + {worktreeToggleLabel} From 837abe64094ed7c0e9401859f090025684f5dd09 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Fri, 13 Mar 2026 11:36:28 +1100 Subject: [PATCH 3/3] fix(web): use outline workspace toggle --- apps/web/src/components/BranchToolbar.tsx | 31 +++-------------------- apps/web/src/components/ChatView.logic.ts | 11 ++++++++ apps/web/src/components/ChatView.tsx | 12 +-------- 3 files changed, 16 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 14273a2a4f..2ba8112f45 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,5 +1,4 @@ import type { ThreadId } from "@t3tools/contracts"; -import { FolderIcon, GitBranchPlusIcon } from "lucide-react"; import { useCallback } from "react"; import { newCommandId } from "../lib/utils"; @@ -22,15 +21,6 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; } -const ENV_MODE_TOGGLE_GROUP_CLASS_NAME = - "rounded-2xl border border-border/70 bg-background/80 p-1 shadow-xs/5 backdrop-blur-sm dark:bg-card/75"; - -const ENV_MODE_TOGGLE_CLASS_NAME = - "gap-2 rounded-xl border border-transparent px-3 text-xs font-medium text-muted-foreground/75 shadow-none transition-all duration-150 hover:bg-background/70 hover:text-foreground/85 data-[pressed]:border-border/80 data-[pressed]:bg-primary/10 data-[pressed]:text-foreground data-[pressed]:ring-1 data-[pressed]:ring-primary/10 data-[pressed]:shadow-sm data-disabled:opacity-100 data-disabled:text-muted-foreground/60 [&_[data-slot=icon-chip]]:bg-background/70 [&_[data-slot=icon-chip]]:ring-1 [&_[data-slot=icon-chip]]:ring-border/40 data-[pressed]:[&_[data-slot=icon-chip]]:bg-primary/12 data-[pressed]:[&_[data-slot=icon-chip]]:ring-primary/20 [&_svg]:size-3.5 [&_svg]:text-muted-foreground/55 [&_svg]:transition-colors data-[pressed]:[&_svg]:text-primary data-disabled:[&_svg]:text-muted-foreground/35"; - -const ENV_MODE_ICON_CHIP_CLASS_NAME = - "flex size-4 shrink-0 items-center justify-center rounded-full transition-colors"; - export default function BranchToolbar({ threadId, onEnvModeChange, @@ -120,8 +110,9 @@ export default function BranchToolbar({
{ @@ -133,33 +124,19 @@ export default function BranchToolbar({ }} > - Local - {worktreeToggleLabel} diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e2904310..f06ca39d9c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -116,6 +116,17 @@ export function cloneComposerImageForRetry( } } +export function extendReplacementRangeForTrailingSpace( + text: string, + rangeEnd: number, + replacement: string, +): number { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; +} + export function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; }): Record> { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 64c15ba2fc..9c1ad09521 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -149,6 +149,7 @@ import { buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, + extendReplacementRangeForTrailingSpace, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -2968,17 +2969,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [composerCursor]); - const extendReplacementRangeForTrailingSpace = ( - text: string, - rangeEnd: number, - replacement: string, - ): number => { - if (!replacement.endsWith(" ")) { - return rangeEnd; - } - return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; - }; - const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null;