Skip to content
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.attachmentUploads).toBe(true);
expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.requiredWorktreeBootstrap).toBe(true);
expect(second.capabilities.usagePriceOverrides).toBe(true);
expect(second.capabilities.threadActiveReorder).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
pullRequests: true,
inlineMessageContext: true,
requiredWorktreeBootstrap: true,
threadSettlement: true,
threadAutoSettlement: true,
threadRestartContinuation: true,
Expand Down
96 changes: 96 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect.each([
{ caseName: "a non-repository", isRepository: false, failFetch: false },
{ caseName: "a base without a commit", isRepository: true, failFetch: false },
{ caseName: "a fetch failure", isRepository: true, failFetch: true },
])(
"rejects required worktree bootstrap before creating a thread for $caseName",
({ isRepository, failFetch }) =>
Effect.gen(function* () {
const dispatchedCommands: Array<OrchestrationCommand> = [];
const createWorktree = vi.fn(
(_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>
Effect.die(new Error("createWorktree must not run before a valid base is found")),
);

yield* buildAppUnderTest({
layers: {
vcsDriver: {
isInsideWorkTree: () => Effect.succeed(isRepository),
},
gitVcsDriver: {
execute: () =>
Effect.succeed({
...SUCCESSFUL_GIT_EXECUTION,
exitCode: ChildProcessSpawner.ExitCode(128),
stderr: "fatal: Needed a single revision",
}),
remoteExists: () => Effect.succeed(true),
fetchRemote: () => Effect.die(new Error("fetch failed before thread creation")),
createWorktree,
},
orchestrationEngine: {
dispatch: (command) =>
Effect.sync(() => {
dispatchedCommands.push(command);
return { sequence: dispatchedCommands.length };
}),
readEvents: () => Stream.empty,
},
},
});

const createdAt = "2026-01-01T00:00:00.000Z";
const wsUrl = yield* getWsServerUrl("/ws");
const result = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
type: "thread.turn.start",
commandId: CommandId.make("cmd-required-worktree"),
threadId: ThreadId.make("thread-required-worktree"),
message: {
messageId: MessageId.make("msg-required-worktree"),
role: "user",
text: "hello",
attachments: [],
},
modelSelection: defaultModelSelection,
runtimeMode: "full-access",
interactionMode: "default",
bootstrap: {
createThread: {
projectId: defaultProjectId,
title: "Bootstrap Thread",
modelSelection: defaultModelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: "main",
worktreePath: null,
createdAt,
},
prepareWorktree: {
projectCwd: "/tmp/project",
baseBranch: "main",
requireWorktree: true,
startFromOrigin: failFetch,
},
},
createdAt,
}),
).pipe(Effect.result),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");
assert.strictEqual(result.failure.bootstrapThreadDisposition, "not-created");
assert.include(
result.failure.message,
failFetch ? "fetch failed" : "separate worktree requires",
);
assert.equal(createWorktree.mock.calls.length, 0);
assert.deepEqual(
dispatchedCommands.map((command) => command.type),
["thread.activity.append"],
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("falls back to the project checkout when worktree mode targets a non-repository", () =>
Effect.gen(function* () {
const dispatchedCommands: Array<OrchestrationCommand> = [];
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,12 @@ const makeWsRpcLayer = (
}

if (prepareWorktree && !shouldPrepareWorktree) {
if (prepareWorktree.requireWorktree) {
return yield* new OrchestrationDispatchCommandError({
message:
"A separate worktree requires a Git repository and a base branch with a commit.",
});
}
// Not a git repo, or the base has no commit: the thread runs in
// the project checkout instead. The card says so and moves on.
yield* track(
Expand Down Expand Up @@ -1401,8 +1407,8 @@ const makeWsRpcLayer = (
// every delete for the prior incarnation committed before it.
// Drain through that event before setup or turn start can own
// terminals and provider sessions under the reused thread id.
yield* threadDeletionReactor.drainThrough(created.sequence);
createdThread = true;
yield* threadDeletionReactor.drainThrough(created.sequence);
// Persist the send now rather than with the turn: the thread is
// real from here on, so any client (or a reload) sees the message
// while the worktree is still being prepared. The turn start
Expand Down Expand Up @@ -1606,13 +1612,16 @@ const makeWsRpcLayer = (
),
onSuccess: (threadDeleted) =>
Effect.fail(
threadDeleted
threadDeleted ||
(bootstrap?.createThread &&
bootstrap.prepareWorktree?.requireWorktree === true &&
!createdThread)
? new OrchestrationDispatchCommandError({
message: dispatchError.message,
...(dispatchError.cause !== undefined
? { cause: dispatchError.cause }
: {}),
bootstrapThreadDisposition: "deleted",
bootstrapThreadDisposition: threadDeleted ? "deleted" : "not-created",
})
: dispatchError,
),
Expand Down
36 changes: 26 additions & 10 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface BranchToolbarHandle {
}

interface BranchToolbarProps {
forceNewWorktree?: boolean;
ref?: Ref<BranchToolbarHandle>;
environmentId: EnvironmentId;
threadId: ThreadId;
Expand All @@ -89,6 +90,7 @@ interface BranchToolbarProps {
}

interface MobileRunContextSelectorProps {
forceNewWorktree: boolean;
autoEnvironmentLabel?: string | undefined;
onAutoEnvironment?: (() => void) | undefined;
envLocked: boolean;
Expand All @@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps {
}

const MobileRunContextSelector = memo(function MobileRunContextSelector({
forceNewWorktree,
autoEnvironmentLabel,
onAutoEnvironment,
envLocked,
Expand All @@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
: activeWorktreePath
? FolderGitIcon
: FolderIcon;
const workspaceLabel = envModeLocked
? resolveLockedWorkspaceLabel(activeWorktreePath)
: effectiveEnvMode === "worktree"
? resolveEnvModeLabel("worktree")
: resolveCurrentWorkspaceLabel(activeWorktreePath);
const workspaceLabel = forceNewWorktree
? resolveEnvModeLabel("worktree")
: envModeLocked
? resolveLockedWorkspaceLabel(activeWorktreePath)
: effectiveEnvMode === "worktree"
? resolveEnvModeLabel("worktree")
: resolveCurrentWorkspaceLabel(activeWorktreePath);
const isLocked = envLocked || envModeLocked;
const workspaceIcon = (
<Tooltip>
Expand Down Expand Up @@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
onEnvModeChange(value as EnvMode);
}}
>
<MenuRadioItem disabled={envModeLocked} value="local">
<MenuRadioItem disabled={envModeLocked || forceNewWorktree} value="local">
<span className="flex min-w-0 items-center gap-1.5">
{activeWorktreePath ? (
<FolderGitIcon className="size-3" />
Expand Down Expand Up @@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
}

export const BranchToolbar = memo(function BranchToolbar({
forceNewWorktree = false,
ref,
environmentId,
threadId,
Expand Down Expand Up @@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({
: null;
const activeProject = useProject(activeProjectRef);
const hasActiveThread = serverThread !== null || draftThread !== null;
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
const activeWorktreePath = forceNewWorktree
? null
: (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null);
const effectiveEnvMode =
effectiveEnvModeOverride ??
(forceNewWorktree ? "worktree" : effectiveEnvModeOverride) ??
resolveEffectiveEnvMode({
activeWorktreePath,
hasServerThread: serverThread !== null,
Expand All @@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({
// "Previous worktree" hops a draft into the most recently active worktree
// of this project — the "keep going where I just was" follow-up flow. Only
// drafts can hop; started server threads have their workspace pinned.
const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked;
const canUsePreviousWorktree =
draftThread !== null && serverThread === null && !envModeLocked && !forceNewWorktree;
const projectRefsForWorktreeLookup = useMemo(
() => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []),
[canUsePreviousWorktree, activeProjectRef],
Expand Down Expand Up @@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({
{showGitControls ? (
<div className="contents @3xl/composer-surface:hidden">
<MobileRunContextSelector
forceNewWorktree={forceNewWorktree}
autoEnvironmentLabel={autoEnvironmentLabel}
onAutoEnvironment={onAutoEnvironment}
envLocked={envLocked}
Expand Down Expand Up @@ -652,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({
)}
{showGitControls ? (
<BranchToolbarEnvModeSelector
forceNewWorktree={forceNewWorktree}
envLocked={envModeLocked}
effectiveEnvMode={effectiveEnvMode}
activeWorktreePath={activeWorktreePath}
Expand All @@ -677,13 +688,18 @@ export const BranchToolbar = memo(function BranchToolbar({

{showGitControls ? (
<BranchToolbarBranchSelector
forceNewWorktree={forceNewWorktree}
ref={branchSelectorRef}
className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"
environmentId={environmentId}
threadId={threadId}
{...(draftId ? { draftId } : {})}
envLocked={envLocked}
{...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
{...(forceNewWorktree
? { effectiveEnvModeOverride: "worktree" }
: effectiveEnvModeOverride
? { effectiveEnvModeOverride }
: {})}
{...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
{...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
startFromOrigin={startFromOrigin}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle {
}

interface BranchToolbarBranchSelectorProps {
forceNewWorktree?: boolean;
ref?: Ref<BranchToolbarBranchSelectorHandle>;
className?: string;
environmentId: EnvironmentId;
Expand All @@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string {
}

export function BranchToolbarBranchSelector({
forceNewWorktree = false,
ref,
className,
environmentId,
Expand Down Expand Up @@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({
activeThreadBranchOverride !== undefined
? activeThreadBranchOverride
: (serverThread?.branch ?? draftThread?.branch ?? null);
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
const activeWorktreePath = forceNewWorktree
? null
: (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null);
const activeProjectCwd = activeProject?.workspaceRoot ?? null;
const branchCwd = activeWorktreePath ?? activeProjectCwd;
const hasServerThread = serverThread !== null;
Expand Down
18 changes: 14 additions & 4 deletions apps/web/src/components/BranchToolbarEnvModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";

interface BranchToolbarEnvModeSelectorProps {
forceNewWorktree?: boolean;
envLocked: boolean;
effectiveEnvMode: EnvMode;
activeWorktreePath: string | null;
Expand All @@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps {
}

export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({
forceNewWorktree = false,
envLocked,
effectiveEnvMode,
activeWorktreePath,
Expand All @@ -51,15 +53,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
[activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],
);

if (envLocked) {
if (envLocked || forceNewWorktree) {
return (
<Tooltip>
<TooltipTrigger
render={<span />}
className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6"
data-composer-context-control
>
{activeWorktreePath ? (
{forceNewWorktree ? (
<FolderGit2Icon className="size-3 shrink-0" />
) : activeWorktreePath ? (
<FolderGitIcon className="size-3 shrink-0" />
) : (
<FolderIcon className="size-3 shrink-0" />
Expand All @@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
data-composer-label-motion
className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
>
{resolveLockedWorkspaceLabel(activeWorktreePath)}
{forceNewWorktree
? resolveEnvModeLabel("worktree")
: resolveLockedWorkspaceLabel(activeWorktreePath)}
</span>
</span>
</TooltipTrigger>
<TooltipPopup>{resolveLockedWorkspaceLabel(activeWorktreePath)}</TooltipPopup>
<TooltipPopup>
{forceNewWorktree
? "Each model starts in its own worktree."
: resolveLockedWorkspaceLabel(activeWorktreePath)}
</TooltipPopup>
</Tooltip>
);
}
Expand Down
Loading
Loading