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..2ba8112f45 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; @@ -47,6 +47,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 +106,40 @@ export default function BranchToolbar({ if (!activeThreadId || !activeProject) return null; return ( -
-
- {envLocked || activeWorktreePath ? ( - - {activeWorktreePath ? "Worktree" : "Local"} - - ) : ( - - )} + Local + + + {worktreeToggleLabel} + +
> { 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; diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index c67fdfbe99..160878e058 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -16,6 +16,7 @@ export default mergeConfig( }, test: { include: [ + "src/components/BranchToolbar.browser.tsx", "src/components/ChatView.browser.tsx", "src/components/KeybindingsToast.browser.tsx", ],