Skip to content

Commit 0694cd4

Browse files
committed
Merge remote-tracking branch 'upstream/main'
# Conflicts: # apps/web/src/components/ChatView.tsx # apps/web/src/components/chat/ChatComposer.tsx # docs/user/thread-sidebar.md
2 parents bdb65c1 + 0150c6a commit 0694cd4

19 files changed

Lines changed: 816 additions & 61 deletions

apps/server/src/environment/ServerEnvironment.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ it.layer(testNodeServices)("ServerEnvironmentLive", (it) => {
181181
expect(second.capabilities.attachmentUploads).toBe(true);
182182
expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
183183
expect(second.capabilities.pullRequests).toBe(true);
184+
expect(second.capabilities.requiredWorktreeBootstrap).toBe(true);
184185
expect(second.capabilities.usagePriceOverrides).toBe(true);
185186
expect(second.capabilities.threadActiveReorder).toBe(true);
186187
expect(second.capabilities.threadVisitedTracking).toBe(true);

apps/server/src/environment/ServerEnvironment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ export const make = Effect.gen(function* () {
246246
fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
247247
pullRequests: true,
248248
inlineMessageContext: true,
249+
requiredWorktreeBootstrap: true,
249250
threadSettlement: true,
250251
threadMessageSearch: true,
251252
threadAutoSettlement: true,

apps/server/src/server.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11741,6 +11741,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1174111741
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
1174211742
);
1174311743

11744+
it.effect.each([
11745+
{ caseName: "a non-repository", isRepository: false, failFetch: false },
11746+
{ caseName: "a base without a commit", isRepository: true, failFetch: false },
11747+
{ caseName: "a fetch failure", isRepository: true, failFetch: true },
11748+
])(
11749+
"rejects required worktree bootstrap before creating a thread for $caseName",
11750+
({ isRepository, failFetch }) =>
11751+
Effect.gen(function* () {
11752+
const dispatchedCommands: Array<OrchestrationCommand> = [];
11753+
const createWorktree = vi.fn(
11754+
(_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>
11755+
Effect.die(new Error("createWorktree must not run before a valid base is found")),
11756+
);
11757+
11758+
yield* buildAppUnderTest({
11759+
layers: {
11760+
vcsDriver: {
11761+
isInsideWorkTree: () => Effect.succeed(isRepository),
11762+
},
11763+
gitVcsDriver: {
11764+
execute: () =>
11765+
Effect.succeed({
11766+
...SUCCESSFUL_GIT_EXECUTION,
11767+
exitCode: ChildProcessSpawner.ExitCode(128),
11768+
stderr: "fatal: Needed a single revision",
11769+
}),
11770+
remoteExists: () => Effect.succeed(true),
11771+
fetchRemote: () => Effect.die(new Error("fetch failed before thread creation")),
11772+
createWorktree,
11773+
},
11774+
orchestrationEngine: {
11775+
dispatch: (command) =>
11776+
Effect.sync(() => {
11777+
dispatchedCommands.push(command);
11778+
return { sequence: dispatchedCommands.length };
11779+
}),
11780+
readEvents: () => Stream.empty,
11781+
},
11782+
},
11783+
});
11784+
11785+
const createdAt = "2026-01-01T00:00:00.000Z";
11786+
const wsUrl = yield* getWsServerUrl("/ws");
11787+
const result = yield* Effect.scoped(
11788+
withWsRpcClient(wsUrl, (client) =>
11789+
client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
11790+
type: "thread.turn.start",
11791+
commandId: CommandId.make("cmd-required-worktree"),
11792+
threadId: ThreadId.make("thread-required-worktree"),
11793+
message: {
11794+
messageId: MessageId.make("msg-required-worktree"),
11795+
role: "user",
11796+
text: "hello",
11797+
attachments: [],
11798+
},
11799+
modelSelection: defaultModelSelection,
11800+
runtimeMode: "full-access",
11801+
interactionMode: "default",
11802+
bootstrap: {
11803+
createThread: {
11804+
projectId: defaultProjectId,
11805+
title: "Bootstrap Thread",
11806+
modelSelection: defaultModelSelection,
11807+
runtimeMode: "full-access",
11808+
interactionMode: "default",
11809+
branch: "main",
11810+
worktreePath: null,
11811+
createdAt,
11812+
},
11813+
prepareWorktree: {
11814+
projectCwd: "/tmp/project",
11815+
baseBranch: "main",
11816+
requireWorktree: true,
11817+
startFromOrigin: failFetch,
11818+
},
11819+
},
11820+
createdAt,
11821+
}),
11822+
).pipe(Effect.result),
11823+
);
11824+
11825+
assertTrue(result._tag === "Failure");
11826+
assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");
11827+
assert.strictEqual(result.failure.bootstrapThreadDisposition, "not-created");
11828+
assert.include(
11829+
result.failure.message,
11830+
failFetch ? "fetch failed" : "separate worktree requires",
11831+
);
11832+
assert.equal(createWorktree.mock.calls.length, 0);
11833+
assert.deepEqual(
11834+
dispatchedCommands.map((command) => command.type),
11835+
["thread.activity.append"],
11836+
);
11837+
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
11838+
);
11839+
1174411840
it.effect("falls back to the project checkout when worktree mode targets a non-repository", () =>
1174511841
Effect.gen(function* () {
1174611842
const dispatchedCommands: Array<OrchestrationCommand> = [];

apps/server/src/ws.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,6 +1411,12 @@ const makeWsRpcLayer = (
14111411
}
14121412

14131413
if (prepareWorktree && !shouldPrepareWorktree) {
1414+
if (prepareWorktree.requireWorktree) {
1415+
return yield* new OrchestrationDispatchCommandError({
1416+
message:
1417+
"A separate worktree requires a Git repository and a base branch with a commit.",
1418+
});
1419+
}
14141420
// Not a git repo, or the base has no commit: the thread runs in
14151421
// the project checkout instead. The card says so and moves on.
14161422
yield* track(
@@ -1443,8 +1449,8 @@ const makeWsRpcLayer = (
14431449
// every delete for the prior incarnation committed before it.
14441450
// Drain through that event before setup or turn start can own
14451451
// terminals and provider sessions under the reused thread id.
1446-
yield* threadDeletionReactor.drainThrough(created.sequence);
14471452
createdThread = true;
1453+
yield* threadDeletionReactor.drainThrough(created.sequence);
14481454
// Persist the send now rather than with the turn: the thread is
14491455
// real from here on, so any client (or a reload) sees the message
14501456
// while the worktree is still being prepared. The turn start
@@ -1648,13 +1654,16 @@ const makeWsRpcLayer = (
16481654
),
16491655
onSuccess: (threadDeleted) =>
16501656
Effect.fail(
1651-
threadDeleted
1657+
threadDeleted ||
1658+
(bootstrap?.createThread &&
1659+
bootstrap.prepareWorktree?.requireWorktree === true &&
1660+
!createdThread)
16521661
? new OrchestrationDispatchCommandError({
16531662
message: dispatchError.message,
16541663
...(dispatchError.cause !== undefined
16551664
? { cause: dispatchError.cause }
16561665
: {}),
1657-
bootstrapThreadDisposition: "deleted",
1666+
bootstrapThreadDisposition: threadDeleted ? "deleted" : "not-created",
16581667
})
16591668
: dispatchError,
16601669
),

apps/web/src/components/BranchToolbar.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export interface BranchToolbarHandle {
6666
}
6767

6868
interface BranchToolbarProps {
69+
forceNewWorktree?: boolean;
6970
ref?: Ref<BranchToolbarHandle>;
7071
environmentId: EnvironmentId;
7172
threadId: ThreadId;
@@ -89,6 +90,7 @@ interface BranchToolbarProps {
8990
}
9091

9192
interface MobileRunContextSelectorProps {
93+
forceNewWorktree: boolean;
9294
autoEnvironmentLabel?: string | undefined;
9395
onAutoEnvironment?: (() => void) | undefined;
9496
envLocked: boolean;
@@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps {
106108
}
107109

108110
const MobileRunContextSelector = memo(function MobileRunContextSelector({
111+
forceNewWorktree,
109112
autoEnvironmentLabel,
110113
onAutoEnvironment,
111114
envLocked,
@@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
132135
: activeWorktreePath
133136
? FolderGitIcon
134137
: FolderIcon;
135-
const workspaceLabel = envModeLocked
136-
? resolveLockedWorkspaceLabel(activeWorktreePath)
137-
: effectiveEnvMode === "worktree"
138-
? resolveEnvModeLabel("worktree")
139-
: resolveCurrentWorkspaceLabel(activeWorktreePath);
138+
const workspaceLabel = forceNewWorktree
139+
? resolveEnvModeLabel("worktree")
140+
: envModeLocked
141+
? resolveLockedWorkspaceLabel(activeWorktreePath)
142+
: effectiveEnvMode === "worktree"
143+
? resolveEnvModeLabel("worktree")
144+
: resolveCurrentWorkspaceLabel(activeWorktreePath);
140145
const isLocked = envLocked || envModeLocked;
141146
const workspaceIcon = (
142147
<Tooltip>
@@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
269274
onEnvModeChange(value as EnvMode);
270275
}}
271276
>
272-
<MenuRadioItem disabled={envModeLocked} value="local">
277+
<MenuRadioItem disabled={envModeLocked || forceNewWorktree} value="local">
273278
<span className="flex min-w-0 items-center gap-1.5">
274279
{activeWorktreePath ? (
275280
<FolderGitIcon className="size-3" />
@@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
476481
}
477482

478483
export const BranchToolbar = memo(function BranchToolbar({
484+
forceNewWorktree = false,
479485
ref,
480486
environmentId,
481487
threadId,
@@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({
514520
: null;
515521
const activeProject = useProject(activeProjectRef);
516522
const hasActiveThread = serverThread !== null || draftThread !== null;
517-
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
523+
const activeWorktreePath = forceNewWorktree
524+
? null
525+
: (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null);
518526
const effectiveEnvMode =
519-
effectiveEnvModeOverride ??
527+
(forceNewWorktree ? "worktree" : effectiveEnvModeOverride) ??
520528
resolveEffectiveEnvMode({
521529
activeWorktreePath,
522530
hasServerThread: serverThread !== null,
@@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({
527535
// "Previous worktree" hops a draft into the most recently active worktree
528536
// of this project — the "keep going where I just was" follow-up flow. Only
529537
// drafts can hop; started server threads have their workspace pinned.
530-
const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked;
538+
const canUsePreviousWorktree =
539+
draftThread !== null && serverThread === null && !envModeLocked && !forceNewWorktree;
531540
const projectRefsForWorktreeLookup = useMemo(
532541
() => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []),
533542
[canUsePreviousWorktree, activeProjectRef],
@@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({
606615
{showGitControls ? (
607616
<div className="contents @3xl/composer-surface:hidden">
608617
<MobileRunContextSelector
618+
forceNewWorktree={forceNewWorktree}
609619
autoEnvironmentLabel={autoEnvironmentLabel}
610620
onAutoEnvironment={onAutoEnvironment}
611621
envLocked={envLocked}
@@ -652,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({
652662
)}
653663
{showGitControls ? (
654664
<BranchToolbarEnvModeSelector
665+
forceNewWorktree={forceNewWorktree}
655666
envLocked={envModeLocked}
656667
effectiveEnvMode={effectiveEnvMode}
657668
activeWorktreePath={activeWorktreePath}
@@ -677,13 +688,18 @@ export const BranchToolbar = memo(function BranchToolbar({
677688

678689
{showGitControls ? (
679690
<BranchToolbarBranchSelector
691+
forceNewWorktree={forceNewWorktree}
680692
ref={branchSelectorRef}
681693
className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"
682694
environmentId={environmentId}
683695
threadId={threadId}
684696
{...(draftId ? { draftId } : {})}
685697
envLocked={envLocked}
686-
{...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
698+
{...(forceNewWorktree
699+
? { effectiveEnvModeOverride: "worktree" }
700+
: effectiveEnvModeOverride
701+
? { effectiveEnvModeOverride }
702+
: {})}
687703
{...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
688704
{...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
689705
startFromOrigin={startFromOrigin}

apps/web/src/components/BranchToolbarBranchSelector.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle {
7979
}
8080

8181
interface BranchToolbarBranchSelectorProps {
82+
forceNewWorktree?: boolean;
8283
ref?: Ref<BranchToolbarBranchSelectorHandle>;
8384
className?: string;
8485
environmentId: EnvironmentId;
@@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string {
99100
}
100101

101102
export function BranchToolbarBranchSelector({
103+
forceNewWorktree = false,
102104
ref,
103105
className,
104106
environmentId,
@@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({
152154
activeThreadBranchOverride !== undefined
153155
? activeThreadBranchOverride
154156
: (serverThread?.branch ?? draftThread?.branch ?? null);
155-
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
157+
const activeWorktreePath = forceNewWorktree
158+
? null
159+
: (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null);
156160
const activeProjectCwd = activeProject?.workspaceRoot ?? null;
157161
const branchCwd = activeWorktreePath ?? activeProjectCwd;
158162
const hasServerThread = serverThread !== null;

apps/web/src/components/BranchToolbarEnvModeSelector.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
2222
const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
2323

2424
interface BranchToolbarEnvModeSelectorProps {
25+
forceNewWorktree?: boolean;
2526
envLocked: boolean;
2627
effectiveEnvMode: EnvMode;
2728
activeWorktreePath: string | null;
@@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps {
3132
}
3233

3334
export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({
35+
forceNewWorktree = false,
3436
envLocked,
3537
effectiveEnvMode,
3638
activeWorktreePath,
@@ -51,15 +53,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
5153
[activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],
5254
);
5355

54-
if (envLocked) {
56+
if (envLocked || forceNewWorktree) {
5557
return (
5658
<Tooltip>
5759
<TooltipTrigger
5860
render={<span />}
5961
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"
6062
data-composer-context-control
6163
>
62-
{activeWorktreePath ? (
64+
{forceNewWorktree ? (
65+
<FolderGit2Icon className="size-3 shrink-0" />
66+
) : activeWorktreePath ? (
6367
<FolderGitIcon className="size-3 shrink-0" />
6468
) : (
6569
<FolderIcon className="size-3 shrink-0" />
@@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
7276
data-composer-label-motion
7377
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"
7478
>
75-
{resolveLockedWorkspaceLabel(activeWorktreePath)}
79+
{forceNewWorktree
80+
? resolveEnvModeLabel("worktree")
81+
: resolveLockedWorkspaceLabel(activeWorktreePath)}
7682
</span>
7783
</span>
7884
</TooltipTrigger>
79-
<TooltipPopup>{resolveLockedWorkspaceLabel(activeWorktreePath)}</TooltipPopup>
85+
<TooltipPopup>
86+
{forceNewWorktree
87+
? "Each model starts in its own worktree."
88+
: resolveLockedWorkspaceLabel(activeWorktreePath)}
89+
</TooltipPopup>
8090
</Tooltip>
8191
);
8292
}

0 commit comments

Comments
 (0)