Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions apps/web/src/components/BranchToolbar.browser.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="branch-selector" />,
}));

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(
<BranchToolbar threadId={THREAD_ID} envLocked={false} onEnvModeChange={onEnvModeChange} />,
);

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(
<BranchToolbar threadId={THREAD_ID} envLocked onEnvModeChange={onEnvModeChange} />,
);

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();
});
});
54 changes: 37 additions & 17 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -103,23 +106,40 @@ export default function BranchToolbar({
if (!activeThreadId || !activeProject) return null;

return (
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">
<div className="flex items-center gap-2">
{envLocked || activeWorktreePath ? (
<span className="border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
{activeWorktreePath ? "Worktree" : "Local"}
</span>
) : (
<Button
type="button"
variant="ghost"
className="text-muted-foreground/70 hover:text-foreground/80"
size="xs"
onClick={() => onEnvModeChange(effectiveEnvMode === "local" ? "worktree" : "local")}
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-5 pb-3 pt-1">
<div className="flex shrink-0 items-center gap-2">
<ToggleGroup
aria-label="Thread workspace mode"
className="shrink-0"
size="xs"
variant="outline"
multiple={false}
value={[envToggleValue]}
onValueChange={(value) => {
if (envToggleDisabled) return;
const nextMode = value[0];
if ((nextMode === "local" || nextMode === "worktree") && nextMode !== envToggleValue) {
onEnvModeChange(nextMode);
}
}}
>
<Toggle
className="px-3"
disabled={envToggleDisabled}
title="Use the local repository"
value="local"
>
{effectiveEnvMode === "worktree" ? "New worktree" : "Local"}
</Button>
)}
Local
</Toggle>
<Toggle
className="px-3"
disabled={envToggleDisabled}
title="Use a separate worktree"
value="worktree"
>
{worktreeToggleLabel}
</Toggle>
</ToggleGroup>
</div>

<BranchToolbarBranchSelector
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
Expand Down
12 changes: 1 addition & 11 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import {
buildTemporaryWorktreeBranchName,
cloneComposerImageForRetry,
collectUserMessageBlobPreviewUrls,
extendReplacementRangeForTrailingSpace,
getCustomModelOptionsByProvider,
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
LastInvokedScriptByProjectSchema,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/web/vitest.browser.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default mergeConfig(
},
test: {
include: [
"src/components/BranchToolbar.browser.tsx",
"src/components/ChatView.browser.tsx",
"src/components/KeybindingsToast.browser.tsx",
],
Expand Down
Loading