Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
resolveEffectiveEnvMode,
resolveEnvModeLabel,
resolveBranchToolbarValue,
resolveLockedWorkspaceLabel,
shouldIncludeBranchPickerItem,
} from "./BranchToolbar.logic";

Expand Down Expand Up @@ -157,6 +158,16 @@ describe("resolveCurrentWorkspaceLabel", () => {
});
});

describe("resolveLockedWorkspaceLabel", () => {
it("uses a shorter label for the main repo checkout", () => {
expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout");
});

it("uses a shorter label for an attached worktree", () => {
expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree");
});
});

describe("deriveLocalBranchNameFromRemoteRef", () => {
it("strips the remote prefix from a remote ref", () => {
expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null):
return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local");
}

export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string {
return activeWorktreePath ? "Worktree" : "Local checkout";
}

export function resolveEffectiveEnvMode(input: {
activeWorktreePath: string | null;
hasServerThread: boolean;
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ interface BranchToolbarProps {
threadId: ThreadId;
draftId?: DraftId;
onEnvModeChange: (mode: EnvMode) => void;
effectiveEnvModeOverride?: EnvMode;
activeThreadBranchOverride?: string | null;
onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
envLocked: boolean;
onCheckoutPullRequestRequest?: (reference: string) => void;
onComposerFocusRequest?: () => void;
Expand All @@ -32,6 +35,9 @@ export const BranchToolbar = memo(function BranchToolbar({
threadId,
draftId,
onEnvModeChange,
effectiveEnvModeOverride,
activeThreadBranchOverride,
onActiveThreadBranchOverrideChange,
envLocked,
onCheckoutPullRequestRequest,
onComposerFocusRequest,
Expand Down Expand Up @@ -59,11 +65,13 @@ export const BranchToolbar = memo(function BranchToolbar({
const activeProject = useStore(activeProjectSelector);
const hasActiveThread = serverThread !== undefined || draftThread !== null;
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
const effectiveEnvMode = resolveEffectiveEnvMode({
activeWorktreePath,
hasServerThread: serverThread !== undefined,
draftThreadEnvMode: draftThread?.envMode,
});
const effectiveEnvMode =
effectiveEnvModeOverride ??
resolveEffectiveEnvMode({
activeWorktreePath,
hasServerThread: serverThread !== undefined,
draftThreadEnvMode: draftThread?.envMode,
});
const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null);

const showEnvironmentPicker =
Expand Down Expand Up @@ -98,6 +106,9 @@ export const BranchToolbar = memo(function BranchToolbar({
threadId={threadId}
{...(draftId ? { draftId } : {})}
envLocked={envLocked}
{...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
{...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
{...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
{...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
{...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
/>
Expand Down
25 changes: 19 additions & 6 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ interface BranchToolbarBranchSelectorProps {
threadId: ThreadId;
draftId?: DraftId;
envLocked: boolean;
effectiveEnvModeOverride?: "local" | "worktree";
activeThreadBranchOverride?: string | null;
onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
onCheckoutPullRequestRequest?: (reference: string) => void;
onComposerFocusRequest?: () => void;
}
Expand Down Expand Up @@ -77,6 +80,9 @@ export function BranchToolbarBranchSelector({
threadId,
draftId,
envLocked,
effectiveEnvModeOverride,
activeThreadBranchOverride,
onActiveThreadBranchOverrideChange,
onCheckoutPullRequestRequest,
onComposerFocusRequest,
}: BranchToolbarBranchSelectorProps) {
Expand Down Expand Up @@ -108,16 +114,21 @@ export function BranchToolbarBranchSelector({
const activeProject = useStore(activeProjectSelector);

const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
const activeThreadBranch =
activeThreadBranchOverride !== undefined
? activeThreadBranchOverride
: (serverThread?.branch ?? draftThread?.branch ?? null);
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
const activeProjectCwd = activeProject?.cwd ?? null;
const branchCwd = activeWorktreePath ?? activeProjectCwd;
const hasServerThread = serverThread !== undefined;
const effectiveEnvMode = resolveEffectiveEnvMode({
activeWorktreePath,
hasServerThread,
draftThreadEnvMode: draftThread?.envMode,
});
const effectiveEnvMode =
effectiveEnvModeOverride ??
resolveEffectiveEnvMode({
activeWorktreePath,
hasServerThread,
draftThreadEnvMode: draftThread?.envMode,
});

// ---------------------------------------------------------------------------
// Thread branch mutation (colocated — only this component calls it)
Expand Down Expand Up @@ -146,6 +157,7 @@ export function BranchToolbarBranchSelector({
});
}
if (hasServerThread) {
onActiveThreadBranchOverrideChange?.(branch);
setThreadBranchAction(threadRef, branch, worktreePath);
return;
}
Expand All @@ -167,6 +179,7 @@ export function BranchToolbarBranchSelector({
serverSession,
activeWorktreePath,
hasServerThread,
onActiveThreadBranchOverrideChange,
setThreadBranchAction,
setDraftThreadContext,
draftId,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/BranchToolbarEnvModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { memo, useMemo } from "react";
import {
resolveCurrentWorkspaceLabel,
resolveEnvModeLabel,
resolveLockedWorkspaceLabel,
type EnvMode,
} from "./BranchToolbar.logic";
import {
Expand Down Expand Up @@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
{activeWorktreePath ? (
<>
<FolderGitIcon className="size-3" />
{resolveCurrentWorkspaceLabel(activeWorktreePath)}
{resolveLockedWorkspaceLabel(activeWorktreePath)}
</>
) : (
<>
<FolderIcon className="size-3" />
{resolveCurrentWorkspaceLabel(activeWorktreePath)}
{resolveLockedWorkspaceLabel(activeWorktreePath)}
</>
)}
</span>
Expand Down
180 changes: 180 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2521,6 +2521,186 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("keeps new-worktree mode on empty server threads and bootstraps the first send", async () => {
const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: {
...snapshot,
threads: snapshot.threads.map((thread) =>
thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread,
),
},
resolveRpc: (body) => {
if (body._tag === WS_METHODS.gitListBranches) {
return {
isRepo: true,
hasOriginRemote: true,
nextCursor: null,
totalCount: 1,
branches: [
{
name: "main",
current: true,
isDefault: true,
worktreePath: null,
},
],
};
}
if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
return {
sequence: fixture.snapshot.snapshotSequence + 1,
};
}
return undefined;
},
});

try {
await page.getByText("Current checkout", { exact: true }).click();
await page.getByText("New worktree", { exact: true }).click();

await vi.waitFor(
() => {
expect(findButtonByText("New worktree")).toBeTruthy();
},
{ timeout: 8_000, interval: 16 },
);

useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
await waitForLayout();

const sendButton = await waitForSendButton();
expect(sendButton.disabled).toBe(false);
sendButton.click();

await vi.waitFor(
() => {
const turnStartRequest = wsRequests.find(
(request) =>
request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
request.type === "thread.turn.start",
) as
| {
_tag: string;
type?: string;
bootstrap?: {
createThread?: { projectId?: string };
prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string };
runSetupScript?: boolean;
};
}
| undefined;

expect(turnStartRequest).toMatchObject({
_tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
type: "thread.turn.start",
bootstrap: {
prepareWorktree: {
projectCwd: "/repo/project",
baseBranch: "main",
branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/),
},
runSetupScript: true,
},
});
expect(turnStartRequest?.bootstrap?.createThread).toBeUndefined();
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("updates the selected worktree base branch on empty server threads", async () => {
const snapshot = addThreadToSnapshot(createDraftOnlySnapshot(), THREAD_ID);
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: {
...snapshot,
threads: snapshot.threads.map((thread) =>
thread.id === THREAD_ID ? Object.assign({}, thread, { session: null }) : thread,
),
},
resolveRpc: (body) => {
if (body._tag === WS_METHODS.gitListBranches) {
return {
isRepo: true,
hasOriginRemote: true,
nextCursor: null,
totalCount: 2,
branches: [
{
name: "main",
current: true,
isDefault: true,
worktreePath: null,
},
{
name: "release/next",
current: false,
isDefault: false,
worktreePath: null,
},
],
};
}
if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
return {
sequence: fixture.snapshot.snapshotSequence + 1,
};
}
return undefined;
},
});

try {
await page.getByText("Current checkout", { exact: true }).click();
await page.getByText("New worktree", { exact: true }).click();
await page.getByText("From main", { exact: true }).click();
await page.getByText("release/next", { exact: true }).click();

await vi.waitFor(
() => {
expect(findButtonByText("From release/next")).toBeTruthy();
},
{ timeout: 8_000, interval: 16 },
);

useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it");
await waitForLayout();

const sendButton = await waitForSendButton();
expect(sendButton.disabled).toBe(false);
sendButton.click();

await vi.waitFor(
() => {
const turnStartRequest = wsRequests.find(
(request) =>
request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
request.type === "thread.turn.start",
) as
| {
_tag: string;
type?: string;
bootstrap?: {
prepareWorktree?: { baseBranch?: string };
};
}
| undefined;

expect(turnStartRequest?.bootstrap?.prepareWorktree?.baseBranch).toBe("release/next");
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("shows the send state once bootstrap dispatch is in flight", async () => {
useTerminalStateStore.setState({
terminalStateByThreadKey: {},
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deriveComposerSendState,
hasServerAcknowledgedLocalDispatch,
reconcileMountedTerminalThreadIds,
resolveSendEnvMode,
shouldWriteThreadErrorToCurrentServerThread,
waitForStartedServerThread,
} from "./ChatView.logic";
Expand Down Expand Up @@ -82,6 +83,17 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
});

describe("resolveSendEnvMode", () => {
it("keeps worktree mode for git repositories", () => {
expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");
});

it("forces local mode for non-git repositories", () => {
expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local");
expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local");
});
});

describe("reconcileMountedTerminalThreadIds", () => {
it("keeps previously mounted open threads and adds the active open thread", () => {
expect(
Expand Down
Loading
Loading