authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-17 03:30:06-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 03:30:06-03:00
log0150c6a53b409ba3bcb45645709b649cf8708354
treea5602edb54156195c884c0189570dae4d31c2eff
parentc0dded88b1fc8c1c86d65f684fbfa520139e58dc
signature Signed by PGP key B5690EEEBB952194

feat(web): start new threads with multiple models in separate worktrees (#12179)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

19 files changed, 814 insertions(+), 56 deletions(-)

apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -170,6 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -170,6 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
170 expect(second.capabilities.attachmentUploads).toBe(true);170 expect(second.capabilities.attachmentUploads).toBe(true);
171 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });171 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
172 expect(second.capabilities.pullRequests).toBe(true);172 expect(second.capabilities.pullRequests).toBe(true);
173 expect(second.capabilities.requiredWorktreeBootstrap).toBe(true);
173 expect(second.capabilities.usagePriceOverrides).toBe(true);174 expect(second.capabilities.usagePriceOverrides).toBe(true);
174 expect(second.capabilities.threadActiveReorder).toBe(true);175 expect(second.capabilities.threadActiveReorder).toBe(true);
175 expect(second.capabilities.threadTitleRegeneration).toBe(true);176 expect(second.capabilities.threadTitleRegeneration).toBe(true);
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {...@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
222 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },222 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
223 pullRequests: true,223 pullRequests: true,
224 inlineMessageContext: true,224 inlineMessageContext: true,
225 requiredWorktreeBootstrap: true,
225 threadSettlement: true,226 threadSettlement: true,
226 threadAutoSettlement: true,227 threadAutoSettlement: true,
227 storageCleanup: true,228 storageCleanup: true,
apps/server/src/server.test.ts+96
...@@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11184 }).pipe(Effect.provide(NodeHttpServer.layerTest)),11184 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11185 );11185 );
1118611186
11187 it.effect.each([
11188 { caseName: "a non-repository", isRepository: false, failFetch: false },
11189 { caseName: "a base without a commit", isRepository: true, failFetch: false },
11190 { caseName: "a fetch failure", isRepository: true, failFetch: true },
11191 ])(
11192 "rejects required worktree bootstrap before creating a thread for $caseName",
11193 ({ isRepository, failFetch }) =>
11194 Effect.gen(function* () {
11195 const dispatchedCommands: Array<OrchestrationCommand> = [];
11196 const createWorktree = vi.fn(
11197 (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) =>
11198 Effect.die(new Error("createWorktree must not run before a valid base is found")),
11199 );
11200
11201 yield* buildAppUnderTest({
11202 layers: {
11203 vcsDriver: {
11204 isInsideWorkTree: () => Effect.succeed(isRepository),
11205 },
11206 gitVcsDriver: {
11207 execute: () =>
11208 Effect.succeed({
11209 ...SUCCESSFUL_GIT_EXECUTION,
11210 exitCode: ChildProcessSpawner.ExitCode(128),
11211 stderr: "fatal: Needed a single revision",
11212 }),
11213 remoteExists: () => Effect.succeed(true),
11214 fetchRemote: () => Effect.die(new Error("fetch failed before thread creation")),
11215 createWorktree,
11216 },
11217 orchestrationEngine: {
11218 dispatch: (command) =>
11219 Effect.sync(() => {
11220 dispatchedCommands.push(command);
11221 return { sequence: dispatchedCommands.length };
11222 }),
11223 readEvents: () => Stream.empty,
11224 },
11225 },
11226 });
11227
11228 const createdAt = "2026-01-01T00:00:00.000Z";
11229 const wsUrl = yield* getWsServerUrl("/ws");
11230 const result = yield* Effect.scoped(
11231 withWsRpcClient(wsUrl, (client) =>
11232 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
11233 type: "thread.turn.start",
11234 commandId: CommandId.make("cmd-required-worktree"),
11235 threadId: ThreadId.make("thread-required-worktree"),
11236 message: {
11237 messageId: MessageId.make("msg-required-worktree"),
11238 role: "user",
11239 text: "hello",
11240 attachments: [],
11241 },
11242 modelSelection: defaultModelSelection,
11243 runtimeMode: "full-access",
11244 interactionMode: "default",
11245 bootstrap: {
11246 createThread: {
11247 projectId: defaultProjectId,
11248 title: "Bootstrap Thread",
11249 modelSelection: defaultModelSelection,
11250 runtimeMode: "full-access",
11251 interactionMode: "default",
11252 branch: "main",
11253 worktreePath: null,
11254 createdAt,
11255 },
11256 prepareWorktree: {
11257 projectCwd: "/tmp/project",
11258 baseBranch: "main",
11259 requireWorktree: true,
11260 startFromOrigin: failFetch,
11261 },
11262 },
11263 createdAt,
11264 }),
11265 ).pipe(Effect.result),
11266 );
11267
11268 assertTrue(result._tag === "Failure");
11269 assertTrue(result.failure._tag === "OrchestrationDispatchCommandError");
11270 assert.strictEqual(result.failure.bootstrapThreadDisposition, "not-created");
11271 assert.include(
11272 result.failure.message,
11273 failFetch ? "fetch failed" : "separate worktree requires",
11274 );
11275 assert.equal(createWorktree.mock.calls.length, 0);
11276 assert.deepEqual(
11277 dispatchedCommands.map((command) => command.type),
11278 ["thread.activity.append"],
11279 );
11280 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11281 );
11282
11187 it.effect("falls back to the project checkout when worktree mode targets a non-repository", () =>11283 it.effect("falls back to the project checkout when worktree mode targets a non-repository", () =>
11188 Effect.gen(function* () {11284 Effect.gen(function* () {
11189 const dispatchedCommands: Array<OrchestrationCommand> = [];11285 const dispatchedCommands: Array<OrchestrationCommand> = [];
apps/server/src/ws.ts+12-3
...@@ -1369,6 +1369,12 @@ const makeWsRpcLayer = (...@@ -1369,6 +1369,12 @@ const makeWsRpcLayer = (
1369 }1369 }
13701370
1371 if (prepareWorktree && !shouldPrepareWorktree) {1371 if (prepareWorktree && !shouldPrepareWorktree) {
1372 if (prepareWorktree.requireWorktree) {
1373 return yield* new OrchestrationDispatchCommandError({
1374 message:
1375 "A separate worktree requires a Git repository and a base branch with a commit.",
1376 });
1377 }
1372 // Not a git repo, or the base has no commit: the thread runs in1378 // Not a git repo, or the base has no commit: the thread runs in
1373 // the project checkout instead. The card says so and moves on.1379 // the project checkout instead. The card says so and moves on.
1374 yield* track(1380 yield* track(
...@@ -1401,8 +1407,8 @@ const makeWsRpcLayer = (...@@ -1401,8 +1407,8 @@ const makeWsRpcLayer = (
1401 // every delete for the prior incarnation committed before it.1407 // every delete for the prior incarnation committed before it.
1402 // Drain through that event before setup or turn start can own1408 // Drain through that event before setup or turn start can own
1403 // terminals and provider sessions under the reused thread id.1409 // terminals and provider sessions under the reused thread id.
1404 yield* threadDeletionReactor.drainThrough(created.sequence);
1405 createdThread = true;1410 createdThread = true;
1411 yield* threadDeletionReactor.drainThrough(created.sequence);
1406 // Persist the send now rather than with the turn: the thread is1412 // Persist the send now rather than with the turn: the thread is
1407 // real from here on, so any client (or a reload) sees the message1413 // real from here on, so any client (or a reload) sees the message
1408 // while the worktree is still being prepared. The turn start1414 // while the worktree is still being prepared. The turn start
...@@ -1606,13 +1612,16 @@ const makeWsRpcLayer = (...@@ -1606,13 +1612,16 @@ const makeWsRpcLayer = (
1606 ),1612 ),
1607 onSuccess: (threadDeleted) =>1613 onSuccess: (threadDeleted) =>
1608 Effect.fail(1614 Effect.fail(
1609 threadDeleted1615 threadDeleted ||
1616 (bootstrap?.createThread &&
1617 bootstrap.prepareWorktree?.requireWorktree === true &&
1618 !createdThread)
1610 ? new OrchestrationDispatchCommandError({1619 ? new OrchestrationDispatchCommandError({
1611 message: dispatchError.message,1620 message: dispatchError.message,
1612 ...(dispatchError.cause !== undefined1621 ...(dispatchError.cause !== undefined
1613 ? { cause: dispatchError.cause }1622 ? { cause: dispatchError.cause }
1614 : {}),1623 : {}),
1615 bootstrapThreadDisposition: "deleted",1624 bootstrapThreadDisposition: threadDeleted ? "deleted" : "not-created",
1616 })1625 })
1617 : dispatchError,1626 : dispatchError,
1618 ),1627 ),
apps/web/src/components/BranchToolbar.tsx+26-10
...@@ -66,6 +66,7 @@ export interface BranchToolbarHandle {...@@ -66,6 +66,7 @@ export interface BranchToolbarHandle {
66}66}
6767
68interface BranchToolbarProps {68interface BranchToolbarProps {
69 forceNewWorktree?: boolean;
69 ref?: Ref<BranchToolbarHandle>;70 ref?: Ref<BranchToolbarHandle>;
70 environmentId: EnvironmentId;71 environmentId: EnvironmentId;
71 threadId: ThreadId;72 threadId: ThreadId;
...@@ -89,6 +90,7 @@ interface BranchToolbarProps {...@@ -89,6 +90,7 @@ interface BranchToolbarProps {
89}90}
9091
91interface MobileRunContextSelectorProps {92interface MobileRunContextSelectorProps {
93 forceNewWorktree: boolean;
92 autoEnvironmentLabel?: string | undefined;94 autoEnvironmentLabel?: string | undefined;
93 onAutoEnvironment?: (() => void) | undefined;95 onAutoEnvironment?: (() => void) | undefined;
94 envLocked: boolean;96 envLocked: boolean;
...@@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps {...@@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps {
106}108}
107109
108const MobileRunContextSelector = memo(function MobileRunContextSelector({110const MobileRunContextSelector = memo(function MobileRunContextSelector({
111 forceNewWorktree,
109 autoEnvironmentLabel,112 autoEnvironmentLabel,
110 onAutoEnvironment,113 onAutoEnvironment,
111 envLocked,114 envLocked,
...@@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
132 : activeWorktreePath135 : activeWorktreePath
133 ? FolderGitIcon136 ? FolderGitIcon
134 : FolderIcon;137 : FolderIcon;
135 const workspaceLabel = envModeLocked138 const workspaceLabel = forceNewWorktree
136 ? resolveLockedWorkspaceLabel(activeWorktreePath)139 ? resolveEnvModeLabel("worktree")
137 : effectiveEnvMode === "worktree"140 : envModeLocked
138 ? resolveEnvModeLabel("worktree")141 ? resolveLockedWorkspaceLabel(activeWorktreePath)
139 : resolveCurrentWorkspaceLabel(activeWorktreePath);142 : effectiveEnvMode === "worktree"
143 ? resolveEnvModeLabel("worktree")
144 : resolveCurrentWorkspaceLabel(activeWorktreePath);
140 const isLocked = envLocked || envModeLocked;145 const isLocked = envLocked || envModeLocked;
141 const workspaceIcon = (146 const workspaceIcon = (
142 <Tooltip>147 <Tooltip>
...@@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
269 onEnvModeChange(value as EnvMode);274 onEnvModeChange(value as EnvMode);
270 }}275 }}
271 >276 >
272 <MenuRadioItem disabled={envModeLocked} value="local">277 <MenuRadioItem disabled={envModeLocked || forceNewWorktree} value="local">
273 <span className="flex min-w-0 items-center gap-1.5">278 <span className="flex min-w-0 items-center gap-1.5">
274 {activeWorktreePath ? (279 {activeWorktreePath ? (
275 <FolderGitIcon className="size-3" />280 <FolderGitIcon className="size-3" />
...@@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
476}481}
477482
478export const BranchToolbar = memo(function BranchToolbar({483export const BranchToolbar = memo(function BranchToolbar({
484 forceNewWorktree = false,
479 ref,485 ref,
480 environmentId,486 environmentId,
481 threadId,487 threadId,
...@@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({
514 : null;520 : null;
515 const activeProject = useProject(activeProjectRef);521 const activeProject = useProject(activeProjectRef);
516 const hasActiveThread = serverThread !== null || draftThread !== null;522 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);
518 const effectiveEnvMode =526 const effectiveEnvMode =
519 effectiveEnvModeOverride ??527 (forceNewWorktree ? "worktree" : effectiveEnvModeOverride) ??
520 resolveEffectiveEnvMode({528 resolveEffectiveEnvMode({
521 activeWorktreePath,529 activeWorktreePath,
522 hasServerThread: serverThread !== null,530 hasServerThread: serverThread !== null,
...@@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({
527 // "Previous worktree" hops a draft into the most recently active worktree535 // "Previous worktree" hops a draft into the most recently active worktree
528 // of this project — the "keep going where I just was" follow-up flow. Only536 // of this project — the "keep going where I just was" follow-up flow. Only
529 // drafts can hop; started server threads have their workspace pinned.537 // 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;
531 const projectRefsForWorktreeLookup = useMemo(540 const projectRefsForWorktreeLookup = useMemo(
532 () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []),541 () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []),
533 [canUsePreviousWorktree, activeProjectRef],542 [canUsePreviousWorktree, activeProjectRef],
...@@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({
606 {showGitControls ? (615 {showGitControls ? (
607 <div className="contents @3xl/composer-surface:hidden">616 <div className="contents @3xl/composer-surface:hidden">
608 <MobileRunContextSelector617 <MobileRunContextSelector
618 forceNewWorktree={forceNewWorktree}
609 autoEnvironmentLabel={autoEnvironmentLabel}619 autoEnvironmentLabel={autoEnvironmentLabel}
610 onAutoEnvironment={onAutoEnvironment}620 onAutoEnvironment={onAutoEnvironment}
611 envLocked={envLocked}621 envLocked={envLocked}
...@@ -652,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -652,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({
652 )}662 )}
653 {showGitControls ? (663 {showGitControls ? (
654 <BranchToolbarEnvModeSelector664 <BranchToolbarEnvModeSelector
665 forceNewWorktree={forceNewWorktree}
655 envLocked={envModeLocked}666 envLocked={envModeLocked}
656 effectiveEnvMode={effectiveEnvMode}667 effectiveEnvMode={effectiveEnvMode}
657 activeWorktreePath={activeWorktreePath}668 activeWorktreePath={activeWorktreePath}
...@@ -677,13 +688,18 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -677,13 +688,18 @@ export const BranchToolbar = memo(function BranchToolbar({
677688
678 {showGitControls ? (689 {showGitControls ? (
679 <BranchToolbarBranchSelector690 <BranchToolbarBranchSelector
691 forceNewWorktree={forceNewWorktree}
680 ref={branchSelectorRef}692 ref={branchSelectorRef}
681 className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"693 className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"
682 environmentId={environmentId}694 environmentId={environmentId}
683 threadId={threadId}695 threadId={threadId}
684 {...(draftId ? { draftId } : {})}696 {...(draftId ? { draftId } : {})}
685 envLocked={envLocked}697 envLocked={envLocked}
686 {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}698 {...(forceNewWorktree
699 ? { effectiveEnvModeOverride: "worktree" }
700 : effectiveEnvModeOverride
701 ? { effectiveEnvModeOverride }
702 : {})}
687 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}703 {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
688 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}704 {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
689 startFromOrigin={startFromOrigin}705 startFromOrigin={startFromOrigin}
apps/web/src/components/BranchToolbarBranchSelector.tsx+5-1
...@@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle {...@@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle {
79}79}
8080
81interface BranchToolbarBranchSelectorProps {81interface BranchToolbarBranchSelectorProps {
82 forceNewWorktree?: boolean;
82 ref?: Ref<BranchToolbarBranchSelectorHandle>;83 ref?: Ref<BranchToolbarBranchSelectorHandle>;
83 className?: string;84 className?: string;
84 environmentId: EnvironmentId;85 environmentId: EnvironmentId;
...@@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string {...@@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string {
99}100}
100101
101export function BranchToolbarBranchSelector({102export function BranchToolbarBranchSelector({
103 forceNewWorktree = false,
102 ref,104 ref,
103 className,105 className,
104 environmentId,106 environmentId,
...@@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({...@@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({
152 activeThreadBranchOverride !== undefined154 activeThreadBranchOverride !== undefined
153 ? activeThreadBranchOverride155 ? activeThreadBranchOverride
154 : (serverThread?.branch ?? draftThread?.branch ?? null);156 : (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);
156 const activeProjectCwd = activeProject?.workspaceRoot ?? null;160 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
157 const branchCwd = activeWorktreePath ?? activeProjectCwd;161 const branchCwd = activeWorktreePath ?? activeProjectCwd;
158 const hasServerThread = serverThread !== null;162 const hasServerThread = serverThread !== null;
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+14-4
...@@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";...@@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
22const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";22const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
2323
24interface BranchToolbarEnvModeSelectorProps {24interface BranchToolbarEnvModeSelectorProps {
25 forceNewWorktree?: boolean;
25 envLocked: boolean;26 envLocked: boolean;
26 effectiveEnvMode: EnvMode;27 effectiveEnvMode: EnvMode;
27 activeWorktreePath: string | null;28 activeWorktreePath: string | null;
...@@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps {...@@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps {
31}32}
3233
33export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({34export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({
35 forceNewWorktree = false,
34 envLocked,36 envLocked,
35 effectiveEnvMode,37 effectiveEnvMode,
36 activeWorktreePath,38 activeWorktreePath,
...@@ -51,7 +53,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -51,7 +53,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
51 [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],53 [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree],
52 );54 );
5355
54 if (envLocked) {56 if (envLocked || forceNewWorktree) {
55 return (57 return (
56 <Tooltip>58 <Tooltip>
57 <TooltipTrigger59 <TooltipTrigger
...@@ -59,7 +61,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -59,7 +61,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
59 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"61 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"
60 data-composer-context-control62 data-composer-context-control
61 >63 >
62 {activeWorktreePath ? (64 {forceNewWorktree ? (
65 <FolderGit2Icon className="size-3 shrink-0" />
66 ) : activeWorktreePath ? (
63 <FolderGitIcon className="size-3 shrink-0" />67 <FolderGitIcon className="size-3 shrink-0" />
64 ) : (68 ) : (
65 <FolderIcon className="size-3 shrink-0" />69 <FolderIcon className="size-3 shrink-0" />
...@@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
72 data-composer-label-motion76 data-composer-label-motion
73 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"77 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"
74 >78 >
75 {resolveLockedWorkspaceLabel(activeWorktreePath)}79 {forceNewWorktree
80 ? resolveEnvModeLabel("worktree")
81 : resolveLockedWorkspaceLabel(activeWorktreePath)}
76 </span>82 </span>
77 </span>83 </span>
78 </TooltipTrigger>84 </TooltipTrigger>
79 <TooltipPopup>{resolveLockedWorkspaceLabel(activeWorktreePath)}</TooltipPopup>85 <TooltipPopup>
86 {forceNewWorktree
87 ? "Each model starts in its own worktree."
88 : resolveLockedWorkspaceLabel(activeWorktreePath)}
89 </TooltipPopup>
80 </Tooltip>90 </Tooltip>
81 );91 );
82 }92 }
apps/web/src/components/ChatView.tsx+389-12
...@@ -46,7 +46,10 @@ import {...@@ -46,7 +46,10 @@ import {
46 type WorktreeSetupSnapshot,46 type WorktreeSetupSnapshot,
47} from "@t3tools/contracts";47} from "@t3tools/contracts";
48import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";48import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
49import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";49import {
50 wasBootstrapThreadDeleted,
51 wasBootstrapThreadNotCreated,
52} from "@t3tools/client-runtime/errors";
50import { readPastedComposerContext } from "./composerInlineTokenPaste";53import { readPastedComposerContext } from "./composerInlineTokenPaste";
51import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";54import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
52import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";55import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
...@@ -82,9 +85,11 @@ import {...@@ -82,9 +85,11 @@ import {
82} from "@t3tools/shared/terminalLabels";85} from "@t3tools/shared/terminalLabels";
83import { Debouncer } from "@tanstack/react-pacer";86import { Debouncer } from "@tanstack/react-pacer";
84import { useAtomValue } from "@effect/atom-react";87import { useAtomValue } from "@effect/atom-react";
88import { Atom } from "effect/unstable/reactivity";
85import {89import {
86 lazy,90 lazy,
87 memo,91 memo,
92 type SetStateAction,
88 Suspense,93 Suspense,
89 useCallback,94 useCallback,
90 useEffect,95 useEffect,
...@@ -265,6 +270,10 @@ import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";...@@ -265,6 +270,10 @@ import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
265import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";270import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
266import { useThreadActions } from "../hooks/useThreadActions";271import { useThreadActions } from "../hooks/useThreadActions";
267import { resolveAppModelSelectionForInstance } from "../modelSelection";272import { resolveAppModelSelectionForInstance } from "../modelSelection";
273import {
274 getComposerPromptInjectionState,
275 getComposerProviderState,
276} from "./chat/composerProviderState";
268import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";277import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
269import { isPreviewFocused } from "../lib/previewFocus";278import { isPreviewFocused } from "../lib/previewFocus";
270import { getTerminalFocusOwner } from "../lib/terminalFocus";279import { getTerminalFocusOwner } from "../lib/terminalFocus";
...@@ -443,6 +452,7 @@ import {...@@ -443,6 +452,7 @@ import {
443 resolveBackgroundDraftWorkspaceOptions,452 resolveBackgroundDraftWorkspaceOptions,
444 resolveComposerInteractionMode,453 resolveComposerInteractionMode,
445 resolveComposerProviderSelection,454 resolveComposerProviderSelection,
455 getAntigravitySendBlockReason,
446 resolveDraftHeroState,456 resolveDraftHeroState,
447 findRecordedWorktreeSetup,457 findRecordedWorktreeSetup,
448 resolveVisibleWorktreeSetup,458 resolveVisibleWorktreeSetup,
...@@ -698,6 +708,14 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null {...@@ -698,6 +708,14 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null {
698 return text.length > 0 ? text : null;708 return text.length > 0 ? text : null;
699}709}
700710
711const draftFanoutStateAtom = Atom.family((_routeKey: string) =>
712 Atom.make({
713 selections: null as ReadonlyArray<ModelSelection> | null,
714 sendInFlight: { current: false },
715 uncertainSubmissions: { current: new Map<string, ThreadId>() },
716 }).pipe(Atom.keepAlive),
717);
718
701function formatOutgoingPrompt(params: {719function formatOutgoingPrompt(params: {
702 provider: ProviderDriverKind;720 provider: ProviderDriverKind;
703 model: string | null;721 model: string | null;
...@@ -1771,7 +1789,23 @@ export default function ChatView(props: ChatViewProps) {...@@ -1771,7 +1789,23 @@ export default function ChatView(props: ChatViewProps) {
1771 const [timelineOverflows, setTimelineOverflows] = useState(false);1789 const [timelineOverflows, setTimelineOverflows] = useState(false);
1772 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});1790 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
1773 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});1791 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
1774 const sendInFlightRef = useRef(false);1792 const fanoutStateAtom = draftFanoutStateAtom(routeThreadKey);
1793 const fanoutState = useAtomValue(fanoutStateAtom);
1794 const sendInFlightRef = fanoutState.sendInFlight;
1795 const composerSendGenerationRef = useRef(0);
1796 const multipleModelSelections = fanoutState.selections;
1797 const setMultipleModelSelections = useCallback(
1798 (selections: SetStateAction<ReadonlyArray<ModelSelection> | null>) => {
1799 appAtomRegistry.update(fanoutStateAtom, (current) => ({
1800 ...current,
1801 selections: typeof selections === "function" ? selections(current.selections) : selections,
1802 }));
1803 },
1804 [fanoutStateAtom],
1805 );
1806 const multipleModelSelectionsRef = useRef(multipleModelSelections);
1807 multipleModelSelectionsRef.current = multipleModelSelections;
1808 const uncertainMultipleSubmissionsRef = fanoutState.uncertainSubmissions;
1775 const environmentUnavailableSendToastSlotRef = useRef(0);1809 const environmentUnavailableSendToastSlotRef = useRef(0);
1776 const feedbackUploadsInFlightRef = useRef(new Set<string>());1810 const feedbackUploadsInFlightRef = useRef(new Set<string>());
1777 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});1811 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});
...@@ -7302,6 +7336,31 @@ export default function ChatView(props: ChatViewProps) {...@@ -7302,6 +7336,31 @@ export default function ChatView(props: ChatViewProps) {
7302 notifyDirectAnnotationAttached();7336 notifyDirectAnnotationAttached();
7303 return;7337 return;
7304 }7338 }
7339 const multipleModelSelections = queuedMessage ? null : sendCtx.multipleModelSelections;
7340 if (
7341 multipleModelSelections !== null &&
7342 serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true
7343 ) {
7344 setThreadError(activeThread.id, "Update this server before starting multiple models.");
7345 return;
7346 }
7347 if (
7348 multipleModelSelections !== null &&
7349 (!isLocalDraftThread ||
7350 !isGitRepo ||
7351 !activeThreadBranch ||
7352 multipleModelSelections.length === 0)
7353 ) {
7354 toastManager.add(
7355 stackedThreadToast({
7356 type: "warning",
7357 title: "Choose models and a base branch",
7358 description:
7359 "Multiple models need a new thread in a Git project. Each gets its own worktree.",
7360 }),
7361 );
7362 return;
7363 }
7305 const {7364 const {
7306 images: sendContextImages,7365 images: sendContextImages,
7307 files: composerFiles,7366 files: composerFiles,
...@@ -7379,7 +7438,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7379,7 +7438,7 @@ export default function ChatView(props: ChatViewProps) {
7379 composerReviewComments.length === 07438 composerReviewComments.length === 0
7380 ? parseCodexFeedbackCommand(trimmed)7439 ? parseCodexFeedbackCommand(trimmed)
7381 : null;7440 : null;
7382 if (feedbackCommand && !queuedMessage) {7441 if (feedbackCommand && !queuedMessage && multipleModelSelections === null) {
7383 if (!isServerThread || activeThread.session === null) {7442 if (!isServerThread || activeThread.session === null) {
7384 toastManager.add(7443 toastManager.add(
7385 stackedThreadToast({7444 stackedThreadToast({
...@@ -7502,7 +7561,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7502,7 +7561,7 @@ export default function ChatView(props: ChatViewProps) {
7502 composerReviewComments.length === 07561 composerReviewComments.length === 0
7503 ? parseStandaloneComposerSlashCommand(trimmed)7562 ? parseStandaloneComposerSlashCommand(trimmed)
7504 : null;7563 : null;
7505 if (standaloneSlashCommand && !queuedMessage) {7564 if (standaloneSlashCommand && !queuedMessage && multipleModelSelections === null) {
7506 handleInteractionModeChange(standaloneSlashCommand);7565 handleInteractionModeChange(standaloneSlashCommand);
7507 promptRef.current = "";7566 promptRef.current = "";
7508 clearComposerDraftContent(composerDraftTarget);7567 clearComposerDraftContent(composerDraftTarget);
...@@ -7652,7 +7711,53 @@ export default function ChatView(props: ChatViewProps) {...@@ -7652,7 +7711,53 @@ export default function ChatView(props: ChatViewProps) {
7652 };7711 };
7653 };7712 };
76547713
7714 const multipleTargets = [];
7715 for (const selection of multipleModelSelections ?? []) {
7716 const provider = providerInstanceEntries.find(
7717 (entry) => entry.instanceId === selection.instanceId,
7718 );
7719 if (!provider?.enabled || !provider.isAvailable || provider.status !== "ready") {
7720 setThreadError(threadIdForSend, `Provider for ${selection.model} is unavailable.`);
7721 return;
7722 }
7723 const providerBlockReason = getAntigravitySendBlockReason(provider.snapshot, selection.model);
7724 if (providerBlockReason) {
7725 setThreadError(threadIdForSend, providerBlockReason);
7726 return;
7727 }
7728 const providerState = getComposerProviderState({
7729 provider: provider.driverKind,
7730 model: selection.model,
7731 models: provider.models,
7732 modelOptions: selection.options,
7733 promptInjectionState: getComposerPromptInjectionState(messageTextForSend),
7734 planModeEnabled: settings.planModeEnabled,
7735 });
7736 const text = formatOutgoingPrompt({
7737 provider: provider.driverKind,
7738 model: selection.model,
7739 models: provider.models,
7740 effort: providerState.promptEffort,
7741 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
7742 });
7743 if (composerRef.current?.validateProviderInput(text) === false) return;
7744 multipleTargets.push({
7745 selection: createModelSelection(
7746 selection.instanceId,
7747 selection.model,
7748 providerState.modelOptionsForDispatch,
7749 ),
7750 text,
7751 interactionMode: resolveComposerInteractionMode({
7752 planModeEnabled: settings.planModeEnabled,
7753 provider: provider.snapshot,
7754 interactionMode: sendInteractionMode,
7755 }).interactionMode,
7756 });
7757 }
7758
7655 sendInFlightRef.current = true;7759 sendInFlightRef.current = true;
7760 const sendGeneration = ++composerSendGenerationRef.current;
7656 // Every early return above leaves a queued message in the queue for a7761 // Every early return above leaves a queued message in the queue for a
7657 // later retry. From here on a failure hands it back to the composer.7762 // later retry. From here on a failure hands it back to the composer.
7658 if (queuedMessage) {7763 if (queuedMessage) {
...@@ -7728,7 +7833,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -7728,7 +7833,9 @@ export default function ChatView(props: ChatViewProps) {
7728 }7833 }
77297834
7730 const resolvedSubmissionIntent =7835 const resolvedSubmissionIntent =
7731 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";7836 (multipleModelSelections !== null || submissionIntent === "background") && isLocalDraftThread
7837 ? "background"
7838 : "foreground";
7732 if (7839 if (
7733 shouldDockDraftHeroForSubmission({7840 shouldDockDraftHeroForSubmission({
7734 isDraftHeroState,7841 isDraftHeroState,
...@@ -7763,7 +7870,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7763,7 +7870,7 @@ export default function ChatView(props: ChatViewProps) {
7763 return;7870 return;
7764 }7871 }
7765 beginLocalDispatch({7872 beginLocalDispatch({
7766 preparingWorktree: Boolean(baseBranchForWorktree),7873 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
7767 submissionIntent: resolvedSubmissionIntent,7874 submissionIntent: resolvedSubmissionIntent,
7768 });7875 });
77697876
...@@ -7792,6 +7899,267 @@ export default function ChatView(props: ChatViewProps) {...@@ -7792,6 +7899,267 @@ export default function ChatView(props: ChatViewProps) {
7792 };7899 };
7793 }),7900 }),
7794 );7901 );
7902 if (multipleModelSelections !== null) {
7903 const failedSelections: ModelSelection[] = [];
7904 let clearedDraft = false;
7905 let releasedComposer = false;
7906 let canRestoreDraft = () => false;
7907 let startedCount = 0;
7908 try {
7909 const attachments = await turnAttachmentsPromise;
7910 const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason;
7911 if (fileBlockReason !== null) throw new Error(fileBlockReason);
7912 const context = buildOutgoingMessageContext(
7913 attachments.map((attachment, index) =>
7914 "id" in attachment && attachment.id !== undefined
7915 ? attachment.id
7916 : composerAttachmentsSnapshot[index]!.id,
7917 ),
7918 );
7919 const title = truncate(
7920 assistantCitationsToPlainText(stripInlineContextReferences(trimmed)).trim() ||
7921 composerAttachmentsSnapshot[0]?.name ||
7922 "New thread",
7923 );
7924 promptRef.current = "";
7925 clearComposerDraftContent(composerDraftTarget);
7926 composerRef.current?.resetCursorState();
7927 clearedDraft = true;
7928 const clearedDraftSnapshot = useComposerDraftStore
7929 .getState()
7930 .getComposerDraft(composerDraftTarget);
7931 const submittedSelections = multipleModelSelectionsRef.current;
7932 canRestoreDraft = () =>
7933 currentRouteThreadKeyRef.current === routeThreadKey &&
7934 composerSendGenerationRef.current === sendGeneration &&
7935 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ===
7936 clearedDraftSnapshot &&
7937 multipleModelSelectionsRef.current === submittedSelections;
7938 setThreadError(threadIdForSend, null);
7939 const starts = Promise.all(
7940 multipleTargets.map(async (target) => {
7941 const retryKey = JSON.stringify([
7942 routeThreadKey,
7943 target.selection.instanceId,
7944 target.selection.model,
7945 ]);
7946 const uncertainThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey);
7947 const targetThreadId = uncertainThreadId ?? newThreadId();
7948 let requestMayHaveStarted = false;
7949 try {
7950 if (uncertainThreadId) {
7951 throw new Error(
7952 "The previous request may have started. Open its thread to check before sending again.",
7953 );
7954 }
7955 const supportsInlineMessageContext =
7956 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment
7957 .capabilities.inlineMessageContext === true;
7958 requestMayHaveStarted = true;
7959 const result = await startThreadTurn({
7960 environmentId,
7961 input: {
7962 threadId: targetThreadId,
7963 message: {
7964 messageId: newMessageId(),
7965 role: "user",
7966 text:
7967 context && !supportsInlineMessageContext
7968 ? serializeLegacyContextMessage({
7969 text: target.text,
7970 records: context.records,
7971 })
7972 : target.text,
7973 attachments,
7974 ...(context && supportsInlineMessageContext ? { context } : {}),
7975 },
7976 modelSelection: target.selection,
7977 titleSeed: title,
7978 runtimeMode,
7979 interactionMode: target.interactionMode,
7980 bootstrap: {
7981 createThread: {
7982 projectId: activeProject.id,
7983 title,
7984 modelSelection: target.selection,
7985 runtimeMode,
7986 interactionMode: target.interactionMode,
7987 branch: activeThreadBranch,
7988 worktreePath: null,
7989 createdAt: messageCreatedAt,
7990 },
7991 prepareWorktree: {
7992 projectCwd: activeProject.workspaceRoot,
7993 baseBranch: activeThreadBranch!,
7994 requireWorktree: true,
7995 branch: buildTemporaryWorktreeBranchName(randomHex),
7996 ...(startFromOrigin ? { startFromOrigin: true } : {}),
7997 },
7998 runSetupScript: true,
7999 },
8000 createdAt: messageCreatedAt,
8001 },
8002 });
8003 if (result._tag === "Failure") {
8004 const error = squashAtomCommandFailure(result);
8005 if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) {
8006 requestMayHaveStarted = false;
8007 }
8008 throw error;
8009 }
8010 startedCount += 1;
8011 } catch (error) {
8012 if (requestMayHaveStarted && !uncertainMultipleSubmissionsRef.current.has(retryKey)) {
8013 uncertainMultipleSubmissionsRef.current.set(retryKey, targetThreadId);
8014 }
8015 failedSelections.push(target.selection);
8016 const retainedThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey);
8017 const failureToastId = toastManager.add(
8018 stackedThreadToast({
8019 type: "error",
8020 title: `Could not start ${target.selection.model}`,
8021 description: error instanceof Error ? error.message : "Failed to send message.",
8022 ...(retainedThreadId
8023 ? {
8024 timeout: 0,
8025 data: {
8026 secondaryActionProps: {
8027 children: "Allow retry",
8028 onClick: () => {
8029 void readLocalApi()
8030 ?.dialogs.confirm(
8031 "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?",
8032 )
8033 .then(
8034 (confirmed) => {
8035 if (
8036 confirmed &&
8037 uncertainMultipleSubmissionsRef.current.get(retryKey) ===
8038 retainedThreadId
8039 ) {
8040 uncertainMultipleSubmissionsRef.current.delete(retryKey);
8041 toastManager.close(failureToastId);
8042 }
8043 },
8044 () => undefined,
8045 );
8046 },
8047 },
8048 },
8049 actionProps: {
8050 children: "Open thread",
8051 onClick: () => {
8052 void navigate({
8053 to: "/$environmentId/$threadId",
8054 params: buildThreadRouteParams(
8055 scopeThreadRef(environmentId, retainedThreadId),
8056 ),
8057 });
8058 },
8059 },
8060 }
8061 : {}),
8062 }),
8063 );
8064 }
8065 }),
8066 );
8067 // Each request now owns its background thread. The original draft is
8068 // ready for another prompt while checkout and setup scripts finish.
8069 sendInFlightRef.current = false;
8070 resetLocalDispatch();
8071 releasedComposer = true;
8072 await starts;
8073 if (startedCount > 0) {
8074 toastManager.add(
8075 stackedThreadToast({
8076 type: "success",
8077 title: `Started ${startedCount} ${startedCount === 1 ? "thread" : "threads"} in background`,
8078 }),
8079 );
8080 }
8081 if (failedSelections.length === 0 && turnUsesAttachmentUploads) {
8082 releaseDraftAttachments(composerAttachmentsSnapshot);
8083 }
8084 } catch (error) {
8085 failedSelections.push(...multipleModelSelections);
8086 setThreadError(
8087 threadIdForSend,
8088 error instanceof Error ? error.message : "Failed to send messages.",
8089 );
8090 } finally {
8091 const restoreFailedDraft = () => {
8092 setMultipleModelSelections(failedSelections);
8093 if (clearedDraft) {
8094 setComposerDraftPrompt(composerDraftTarget, messageTextForSend);
8095 addComposerDraftImages(
8096 composerDraftTarget,
8097 composerImagesSnapshot.map(cloneComposerImageForRetry),
8098 );
8099 addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot);
8100 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
8101 setComposerDraftPreviewAnnotations(
8102 composerDraftTarget,
8103 composerPreviewAnnotationsSnapshot,
8104 );
8105 setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot);
8106 if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) {
8107 promptRef.current = messageTextForSend;
8108 composerRef.current.resetCursorState({
8109 cursor: collapseExpandedComposerCursor(
8110 messageTextForSend,
8111 messageTextForSend.length,
8112 ),
8113 prompt: messageTextForSend,
8114 detectTrigger: true,
8115 });
8116 }
8117 }
8118 };
8119 if (failedSelections.length > 0) {
8120 if (canRestoreDraft() && composerRef.current) {
8121 restoreFailedDraft();
8122 } else if (clearedDraft) {
8123 const recoveryToastId = toastManager.add(
8124 stackedThreadToast({
8125 type: "error",
8126 title: "A background prompt could not be sent",
8127 description:
8128 "Your newer draft is unchanged. Restore the failed prompt when this composer is empty.",
8129 timeout: 0,
8130 actionProps: {
8131 children: "Restore prompt",
8132 onClick: () => {
8133 if (
8134 !draftId ||
8135 !useComposerDraftStore.getState().getDraftSession(draftId) ||
8136 sendInFlightRef.current ||
8137 composerDraftHasUserContent(
8138 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
8139 )
8140 ) {
8141 toastManager.update(recoveryToastId, {
8142 description:
8143 "Return to the original draft and send or clear its current prompt before restoring.",
8144 });
8145 return;
8146 }
8147 restoreFailedDraft();
8148 void navigate({ to: "/draft/$draftId", params: { draftId } });
8149 toastManager.close(recoveryToastId);
8150 },
8151 },
8152 }),
8153 );
8154 }
8155 }
8156 if (!releasedComposer) {
8157 sendInFlightRef.current = false;
8158 resetLocalDispatch();
8159 }
8160 }
8161 return;
8162 }
7795 const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) =>8163 const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) =>
7796 attachment.type === "image"8164 attachment.type === "image"
7797 ? {8165 ? {
...@@ -8868,7 +9236,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8868,7 +9236,7 @@ export default function ChatView(props: ChatViewProps) {
8868 );9236 );
88699237
8870 const onProviderModelSelect = useCallback(9238 const onProviderModelSelect = useCallback(
8871 (instanceId: ProviderInstanceId, model: string) => {9239 (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => {
8872 if (!activeThread) return;9240 if (!activeThread) return;
8873 // Look up the configured instance so model normalization and custom9241 // Look up the configured instance so model normalization and custom
8874 // model lookup stay scoped to that exact instance. Unknown instance ids9242 // model lookup stay scoped to that exact instance. Unknown instance ids
...@@ -8880,7 +9248,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8880,7 +9248,7 @@ export default function ChatView(props: ChatViewProps) {
8880 resolvedDriverKind !== null &&9248 resolvedDriverKind !== null &&
8881 resolvedDriverKind !== lockedProvider9249 resolvedDriverKind !== lockedProvider
8882 ) {9250 ) {
8883 scheduleComposerFocus();9251 if (options?.focusComposer !== false) scheduleComposerFocus();
8884 return;9252 return;
8885 }9253 }
8886 if (lockedProvider !== null && activeThread.session?.providerInstanceId) {9254 if (lockedProvider !== null && activeThread.session?.providerInstanceId) {
...@@ -8892,7 +9260,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8892,7 +9260,7 @@ export default function ChatView(props: ChatViewProps) {
8892 entry?.continuation?.groupKey &&9260 entry?.continuation?.groupKey &&
8893 currentEntry.continuation.groupKey !== entry.continuation.groupKey9261 currentEntry.continuation.groupKey !== entry.continuation.groupKey
8894 ) {9262 ) {
8895 scheduleComposerFocus();9263 if (options?.focusComposer !== false) scheduleComposerFocus();
8896 return;9264 return;
8897 }9265 }
8898 }9266 }
...@@ -8903,7 +9271,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8903,7 +9271,7 @@ export default function ChatView(props: ChatViewProps) {
8903 model,9271 model,
8904 );9272 );
8905 if (!resolvedModel) {9273 if (!resolvedModel) {
8906 scheduleComposerFocus();9274 if (options?.focusComposer !== false) scheduleComposerFocus();
8907 return;9275 return;
8908 }9276 }
8909 const nextModelSelection: ModelSelection = {9277 const nextModelSelection: ModelSelection = {
...@@ -8923,7 +9291,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8923,7 +9291,7 @@ export default function ChatView(props: ChatViewProps) {
8923 title: modelChangeBlockReason.title,9291 title: modelChangeBlockReason.title,
8924 description: modelChangeBlockReason.description,9292 description: modelChangeBlockReason.description,
8925 });9293 });
8926 scheduleComposerFocus();9294 if (options?.focusComposer !== false) scheduleComposerFocus();
8927 return;9295 return;
8928 }9296 }
8929 setComposerDraftModelSelection(9297 setComposerDraftModelSelection(
...@@ -8932,7 +9300,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8932,7 +9300,7 @@ export default function ChatView(props: ChatViewProps) {
8932 { explicit: true },9300 { explicit: true },
8933 );9301 );
8934 setStickyComposerModelSelection(nextModelSelection);9302 setStickyComposerModelSelection(nextModelSelection);
8935 scheduleComposerFocus();9303 if (options?.focusComposer !== false) scheduleComposerFocus();
8936 },9304 },
8937 [9305 [
8938 activeThread,9306 activeThread,
...@@ -8946,6 +9314,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8946,6 +9314,7 @@ export default function ChatView(props: ChatViewProps) {
8946 );9314 );
8947 const onEnvModeChange = useCallback(9315 const onEnvModeChange = useCallback(
8948 (mode: DraftThreadEnvMode) => {9316 (mode: DraftThreadEnvMode) => {
9317 if (multipleModelSelections !== null) return;
8949 if (canOverrideServerThreadEnvMode) {9318 if (canOverrideServerThreadEnvMode) {
8950 setPendingServerThreadEnvMode(mode);9319 setPendingServerThreadEnvMode(mode);
8951 scheduleComposerFocus();9320 scheduleComposerFocus();
...@@ -8968,6 +9337,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8968,6 +9337,7 @@ export default function ChatView(props: ChatViewProps) {
8968 composerDraftTarget,9337 composerDraftTarget,
8969 draftThread?.worktreePath,9338 draftThread?.worktreePath,
8970 isLocalDraftThread,9339 isLocalDraftThread,
9340 multipleModelSelections,
8971 activeProjectSettings.settings.newWorktreesStartFromOrigin,9341 activeProjectSettings.settings.newWorktreesStartFromOrigin,
8972 setPendingServerThreadEnvMode,9342 setPendingServerThreadEnvMode,
8973 scheduleComposerFocus,9343 scheduleComposerFocus,
...@@ -9632,6 +10002,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -9632,6 +10002,12 @@ export default function ChatView(props: ChatViewProps) {
9632 <ComposerSurface.Host>10002 <ComposerSurface.Host>
9633 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">10003 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
9634 <ChatComposer10004 <ChatComposer
10005 multipleModelSelections={multipleModelSelections}
10006 supportsMultipleModels={
10007 serverConfig?.environment.capabilities.requiredWorktreeBootstrap ===
10008 true
10009 }
10010 onMultipleModelSelectionsChange={setMultipleModelSelections}
9635 composerRef={composerRef}10011 composerRef={composerRef}
9636 composerDraftTarget={composerDraftTarget}10012 composerDraftTarget={composerDraftTarget}
9637 environmentId={environmentId}10013 environmentId={environmentId}
...@@ -9769,6 +10145,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9769,6 +10145,7 @@ export default function ChatView(props: ChatViewProps) {
9769 {mountComposerContextStrip && (10145 {mountComposerContextStrip && (
9770 <div className="pointer-events-auto">10146 <div className="pointer-events-auto">
9771 <BranchToolbar10147 <BranchToolbar
10148 forceNewWorktree={multipleModelSelections !== null}
9772 ref={branchToolbarRef}10149 ref={branchToolbarRef}
9773 environmentId={activeThread.environmentId}10150 environmentId={activeThread.environmentId}
9774 threadId={activeThread.id}10151 threadId={activeThread.id}
apps/web/src/components/chat/ChatComposer.tsx+77-6
...@@ -245,6 +245,7 @@ import {...@@ -245,6 +245,7 @@ import {
245import { useEnvironmentQuery } from "~/state/query";245import { useEnvironmentQuery } from "~/state/query";
246import { useDebouncedValue } from "~/state/queries";246import { useDebouncedValue } from "~/state/queries";
247import { ProviderModelPicker } from "./ProviderModelPicker";247import { ProviderModelPicker } from "./ProviderModelPicker";
248import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
248import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";249import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
249import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";250import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
250import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";251import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
...@@ -1267,6 +1268,7 @@ export interface ChatComposerHandle {...@@ -1267,6 +1268,7 @@ export interface ChatComposerHandle {
1267 selectedPromptEffort: string | null;1268 selectedPromptEffort: string | null;
1268 selectedModelOptionsForDispatch: unknown;1269 selectedModelOptionsForDispatch: unknown;
1269 selectedModelSelection: ModelSelection;1270 selectedModelSelection: ModelSelection;
1271 multipleModelSelections: ReadonlyArray<ModelSelection> | null;
1270 providerAvailable: boolean;1272 providerAvailable: boolean;
1271 selectedProvider: ProviderDriverKind;1273 selectedProvider: ProviderDriverKind;
1272 selectedModel: string;1274 selectedModel: string;
...@@ -1276,6 +1278,7 @@ export interface ChatComposerHandle {...@@ -1276,6 +1278,7 @@ export interface ChatComposerHandle {
1276 };1278 };
1277 /** Validate the fully composed text immediately before a provider turn starts. */1279 /** Validate the fully composed text immediately before a provider turn starts. */
1278 validateProviderInput: (providerInput: string) => boolean;1280 validateProviderInput: (providerInput: string) => boolean;
1281 setMultipleModelSelections: (selections: ReadonlyArray<ModelSelection>) => void;
1279}1282}
12801283
1281// --------------------------------------------------------------------------1284// --------------------------------------------------------------------------
...@@ -1292,6 +1295,11 @@ export interface ChatComposerProps {...@@ -1292,6 +1295,11 @@ export interface ChatComposerProps {
1292 routeKind: "server" | "draft";1295 routeKind: "server" | "draft";
1293 routeThreadRef: ScopedThreadRef;1296 routeThreadRef: ScopedThreadRef;
1294 draftId: DraftId | null;1297 draftId: DraftId | null;
1298 multipleModelSelections: ReadonlyArray<ModelSelection> | null;
1299 supportsMultipleModels: boolean;
1300 onMultipleModelSelectionsChange: React.Dispatch<
1301 React.SetStateAction<ReadonlyArray<ModelSelection> | null>
1302 >;
12951303
1296 // Thread context1304 // Thread context
1297 activeThreadId: ThreadId | null;1305 activeThreadId: ThreadId | null;
...@@ -1420,7 +1428,11 @@ export interface ChatComposerProps {...@@ -1420,7 +1428,11 @@ export interface ChatComposerProps {
1420 cursorAdjacentToMention: boolean,1428 cursorAdjacentToMention: boolean,
1421 ) => void;1429 ) => void;
14221430
1423 onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void;1431 onProviderModelSelect: (
1432 instanceId: ProviderInstanceId,
1433 model: string,
1434 options?: { focusComposer?: boolean },
1435 ) => void;
1424 onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;1436 onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
1425 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;1437 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;
1426 toggleInteractionMode: () => void;1438 toggleInteractionMode: () => void;
...@@ -1449,6 +1461,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1449,6 +1461,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1449 routeKind,1461 routeKind,
1450 routeThreadRef,1462 routeThreadRef,
1451 draftId,1463 draftId,
1464 multipleModelSelections,
1465 supportsMultipleModels,
1466 onMultipleModelSelectionsChange: setMultipleModelSelections,
1452 activeThreadId,1467 activeThreadId,
1453 activeThreadEnvironmentId: _activeThreadEnvironmentId,1468 activeThreadEnvironmentId: _activeThreadEnvironmentId,
1454 activeThread,1469 activeThread,
...@@ -1847,7 +1862,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1847,7 +1862,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1847 );1862 );
1848 const selectedInstanceId =1863 const selectedInstanceId =
1849 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;1864 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
1850 const noProviderAvailable = selectedProviderEntry === undefined;1865 const noProviderAvailable =
1866 selectedProviderEntry === undefined && multipleModelSelections === null;
1851 // Before the catalog arrives, every thread resolves to "no provider". Send1867 // Before the catalog arrives, every thread resolves to "no provider". Send
1852 // stays blocked either way; only the chrome waits, keeping the picker with1868 // stays blocked either way; only the chrome waits, keeping the picker with
1853 // the thread's own selection instead of swapping in the setup button and1869 // the thread's own selection instead of swapping in the setup button and
...@@ -1883,9 +1899,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1883,9 +1899,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1883 );1899 );
1884 const sendDisabledReason =1900 const sendDisabledReason =
1885 externalSendDisabledReason ??1901 externalSendDisabledReason ??
1902 (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ??
1886 (activePendingProgress1903 (activePendingProgress
1887 ? attachmentBlockReason1904 ? attachmentBlockReason
1888 : (attachmentBlockReason ?? providerSendBlockReason));1905 : (attachmentBlockReason ??
1906 (multipleModelSelections === null ? providerSendBlockReason : null)));
1889 const isSendDisabled = sendDisabledReason !== null;1907 const isSendDisabled = sendDisabledReason !== null;
1890 const selectedProviderStatus = useMemo(1908 const selectedProviderStatus = useMemo(
1891 () => selectedProviderEntry?.snapshot ?? null,1909 () => selectedProviderEntry?.snapshot ?? null,
...@@ -4943,7 +4961,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4943,7 +4961,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4943 ) : null}4961 ) : null}
4944 <ProviderModelPicker4962 <ProviderModelPicker
4945 isComposerOwned4963 isComposerOwned
4946 disabled={providerCatalogPending}4964 disabled={providerCatalogPending || isSendBusy}
4965 {...(routeKind === "draft" && supportsMultipleModels
4966 ? {
4967 ...(multipleModelSelections !== null
4968 ? { selectedModels: multipleModelSelections }
4969 : {}),
4970 onToggleModel: (instanceId: ProviderInstanceId, model: string) => {
4971 const current = multipleModelSelections ?? [selectedModelSelection];
4972 const matchesModel = (selection: ModelSelection) => {
4973 if (selection.instanceId !== instanceId) return false;
4974 const entry = providerInstanceEntries.find(
4975 (entry) => entry.instanceId === selection.instanceId,
4976 );
4977 const resolvedModel = resolveModelPickerSelectedModel({
4978 driverKind: entry?.driverKind,
4979 model: selection.model,
4980 options: modelOptionsByInstance.get(selection.instanceId) ?? [],
4981 });
4982 return (resolvedModel?.slug ?? selection.model) === model;
4983 };
4984 const exists = current.some(matchesModel);
4985 const next = exists
4986 ? current.filter((selection) => !matchesModel(selection))
4987 : [...current, createModelSelection(instanceId, model)];
4988 if (next.length > 1) {
4989 setMultipleModelSelections(next);
4990 } else {
4991 setMultipleModelSelections(null);
4992 const remaining = next[0] ?? selectedModelSelection;
4993 onProviderModelSelect(remaining.instanceId, remaining.model, {
4994 focusComposer: false,
4995 });
4996 }
4997 },
4998 }
4999 : {})}
4947 activeInstanceId={5000 activeInstanceId={
4948 providerCatalogPending5001 providerCatalogPending
4949 ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId)5002 ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId)
...@@ -4983,7 +5036,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4983,7 +5036,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4983 : {})}5036 : {})}
4984 onOpenChange={setIsComposerModelPickerOpen}5037 onOpenChange={setIsComposerModelPickerOpen}
4985 getModelDisabledReason={getModelDisabledReason}5038 getModelDisabledReason={getModelDisabledReason}
4986 onInstanceModelChange={onProviderModelSelect}5039 onInstanceModelChange={(instanceId, model) => {
5040 setMultipleModelSelections(null);
5041 onProviderModelSelect(instanceId, model);
5042 }}
4987 onOpenProviderSetup={onOpenProviderSetup}5043 onOpenProviderSetup={onOpenProviderSetup}
4988 />5044 />
49895045
...@@ -5902,13 +5958,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5902,13 +5958,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5902 selectedPromptEffort,5958 selectedPromptEffort,
5903 selectedModelOptionsForDispatch,5959 selectedModelOptionsForDispatch,
5904 selectedModelSelection,5960 selectedModelSelection,
5905 providerAvailable: !noProviderAvailable && providerSendBlockReason === null,5961 multipleModelSelections:
5962 routeKind === "draft" && multipleModelSelections !== null
5963 ? multipleModelSelections.map((selection) =>
5964 selection.instanceId === selectedModelSelection.instanceId &&
5965 selection.model === selectedModelSelection.model
5966 ? selectedModelSelection
5967 : selection,
5968 )
5969 : null,
5970 providerAvailable:
5971 multipleModelSelections !== null ||
5972 (!noProviderAvailable && providerSendBlockReason === null),
5906 selectedProvider,5973 selectedProvider,
5907 selectedModel,5974 selectedModel,
5908 selectedProviderModels,5975 selectedProviderModels,
5909 interactionMode,5976 interactionMode,
5910 interactionModeEnabled: planModeUiEnabled,5977 interactionModeEnabled: planModeUiEnabled,
5911 }),5978 }),
5979 setMultipleModelSelections,
5912 validateProviderInput: (providerInput: string) => {5980 validateProviderInput: (providerInput: string) => {
5913 const validationMessage = getComposerSubmissionValidationMessage({5981 const validationMessage = getComposerSubmissionValidationMessage({
5914 prompt: promptRef.current,5982 prompt: promptRef.current,
...@@ -5951,6 +6019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5951,6 +6019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5951 selectedModel,6019 selectedModel,
5952 selectedModelOptionsForDispatch,6020 selectedModelOptionsForDispatch,
5953 selectedModelSelection,6021 selectedModelSelection,
6022 multipleModelSelections,
6023 setMultipleModelSelections,
6024 routeKind,
5954 noProviderAvailable,6025 noProviderAvailable,
5955 providerSendBlockReason,6026 providerSendBlockReason,
5956 selectedPromptEffort,6027 selectedPromptEffort,
apps/web/src/components/chat/ModelListRow.tsx+5-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";1import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
2import { memo } from "react";2import { memo } from "react";
3import { StarIcon } from "lucide-react";3import { CheckIcon, StarIcon } from "lucide-react";
4import {4import {
5 getDisplayModelName,5 getDisplayModelName,
6 getTriggerDisplayModelLabel,6 getTriggerDisplayModelLabel,
...@@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: {...@@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
31 providerAccentColor?: string | undefined;31 providerAccentColor?: string | undefined;
32 isFavorite: boolean;32 isFavorite: boolean;
33 isSelected: boolean;33 isSelected: boolean;
34 showSelection?: boolean;
34 showProvider: boolean;35 showProvider: boolean;
35 preferShortName?: boolean;36 preferShortName?: boolean;
36 useTriggerLabel?: boolean;37 useTriggerLabel?: boolean;
...@@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: {...@@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: {
94 </div>95 </div>
9596
96 <div className="flex shrink-0 items-center gap-1.5">97 <div className="flex shrink-0 items-center gap-1.5">
98 {props.showSelection && props.isSelected ? (
99 <CheckIcon className="size-3.5" aria-hidden="true" />
100 ) : null}
97 {props.jumpLabel ? (101 {props.jumpLabel ? (
98 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">{props.jumpLabel}</Kbd>102 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">{props.jumpLabel}</Kbd>
99 ) : null}103 ) : null}
apps/web/src/components/chat/ModelPickerContent.tsx+55-11
...@@ -151,6 +151,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -151,6 +151,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
151 /** The instance currently selected in the composer (combobox "value"). */151 /** The instance currently selected in the composer (combobox "value"). */
152 activeInstanceId: ProviderInstanceId;152 activeInstanceId: ProviderInstanceId;
153 model: string;153 model: string;
154 selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>;
155 onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void;
154 /**156 /**
155 * When set, the picker is locked to the given driver kind — typically157 * When set, the picker is locked to the given driver kind — typically
156 * because the user is editing a previously-sent message and can't change158 * because the user is editing a previously-sent message and can't change
...@@ -186,6 +188,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -186,6 +188,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
186 instanceEntries,188 instanceEntries,
187 getModelDisabledReason,189 getModelDisabledReason,
188 onInstanceModelChange,190 onInstanceModelChange,
191 onToggleModel,
189 } = props;192 } = props;
190 const [searchQuery, setSearchQuery] = useState("");193 const [searchQuery, setSearchQuery] = useState("");
191 const [showTopScrollFade, setShowTopScrollFade] = useState(false);194 const [showTopScrollFade, setShowTopScrollFade] = useState(false);
...@@ -207,6 +210,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -207,6 +210,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
207 const activeModelKey = activeModelSlug210 const activeModelKey = activeModelSlug
208 ? modelPickerModelKey(props.activeInstanceId, activeModelSlug)211 ? modelPickerModelKey(props.activeInstanceId, activeModelSlug)
209 : null;212 : null;
213 const selectedModelKeys = useMemo(
214 () =>
215 props.selectedModels?.map((selection) => {
216 const entry = instanceEntries.find((entry) => entry.instanceId === selection.instanceId);
217 const model = resolveModelPickerSelectedModel({
218 driverKind: entry?.driverKind,
219 model: selection.model,
220 options: modelOptionsByInstance.get(selection.instanceId) ?? [],
221 });
222 return modelPickerModelKey(selection.instanceId, model?.slug ?? selection.model);
223 }),
224 [instanceEntries, modelOptionsByInstance, props.selectedModels],
225 );
226 const selectedModelKeySet = useMemo(
227 () => new Set(selectedModelKeys ?? (activeModelKey ? [activeModelKey] : [])),
228 [selectedModelKeys, activeModelKey],
229 );
210 const activeInstanceHasSelectableUnavailableModel =230 const activeInstanceHasSelectableUnavailableModel =
211 activeEntry !== undefined &&231 activeEntry !== undefined &&
212 (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>232 (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>
...@@ -575,7 +595,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -575,7 +595,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
575 }, []);595 }, []);
576596
577 const handleModelSelect = useCallback(597 const handleModelSelect = useCallback(
578 (modelSlug: string, instanceId: ProviderInstanceId) => {598 (modelSlug: string, instanceId: ProviderInstanceId, additive = false) => {
579 if (getModelDisabledReason?.(instanceId, modelSlug)) {599 if (getModelDisabledReason?.(instanceId, modelSlug)) {
580 return;600 return;
581 }601 }
...@@ -592,10 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -592,10 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
592 // normalization rules, so pass the driver kind here.612 // normalization rules, so pass the driver kind here.
593 const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options);613 const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options);
594 if (resolvedModel) {614 if (resolvedModel) {
595 onInstanceModelChange(instanceId, resolvedModel);615 if (additive && onToggleModel) {
616 onToggleModel(instanceId, resolvedModel);
617 } else {
618 onInstanceModelChange(instanceId, resolvedModel);
619 }
596 }620 }
597 },621 },
598 [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange],622 [
623 entryByInstanceId,
624 getModelDisabledReason,
625 modelOptionsByInstance,
626 onInstanceModelChange,
627 onToggleModel,
628 ],
599 );629 );
600630
601 const toggleFavorite = useCallback(631 const toggleFavorite = useCallback(
...@@ -701,8 +731,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -701,8 +731,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
701 return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;731 return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;
702 }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);732 }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);
703 const modelListExtraData = useMemo(733 const modelListExtraData = useMemo(
704 () => ({ favoritesSet, modelJumpLabelByKey }),734 () => ({ favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet }),
705 [favoritesSet, modelJumpLabelByKey],735 [favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet],
706 );736 );
707737
708 useEffect(() => {738 useEffect(() => {
...@@ -804,7 +834,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -804,7 +834,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
804 )}834 )}
805835
806 {/* Main content area */}836 {/* Main content area */}
807 <Combobox837 <Combobox<string, boolean>
808 inline838 inline
809 items={allItemKeys}839 items={allItemKeys}
810 filteredItems={filteredItemKeys}840 filteredItems={filteredItemKeys}
...@@ -812,7 +842,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -812,7 +842,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
812 autoHighlight842 autoHighlight
813 open843 open
814 virtualized844 virtualized
815 value={activeModelKey}845 multiple={onToggleModel !== undefined}
846 value={onToggleModel ? [...selectedModelKeySet] : activeModelKey}
816 onItemHighlighted={(modelKey, eventDetails) => {847 onItemHighlighted={(modelKey, eventDetails) => {
817 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;848 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
818 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {849 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {
...@@ -822,7 +853,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -822,7 +853,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
822 });853 });
823 }854 }
824 }}855 }}
825 onValueChange={(modelKey) => {856 onValueChange={(value, details) => {
857 const modelKey = Array.isArray(value)
858 ? (value.find((key) => !selectedModelKeySet.has(key)) ??
859 [...selectedModelKeySet].find((key) => !value.includes(key)))
860 : value;
826 if (typeof modelKey !== "string") {861 if (typeof modelKey !== "string") {
827 return;862 return;
828 }863 }
...@@ -833,7 +868,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -833,7 +868,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
833 }868 }
834 const model = parseModelPickerModelKey(modelKey);869 const model = parseModelPickerModelKey(modelKey);
835 if (model) {870 if (model) {
836 handleModelSelect(model.slug, model.instanceId);871 handleModelSelect(
872 model.slug,
873 model.instanceId,
874 "shiftKey" in details.event && details.event.shiftKey === true,
875 );
837 }876 }
838 }}877 }}
839 >878 >
...@@ -901,7 +940,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -901,7 +940,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
901 }940 }
902 const model = parseModelPickerModelKey(highlightedModelKeyRef.current);941 const model = parseModelPickerModelKey(highlightedModelKeyRef.current);
903 if (model) {942 if (model) {
904 handleModelSelect(model.slug, model.instanceId);943 handleModelSelect(model.slug, model.instanceId, e.shiftKey);
905 }944 }
906 return;945 return;
907 }946 }
...@@ -967,7 +1006,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -967,7 +1006,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
967 isFavorite={favoritesSet.has(1006 isFavorite={favoritesSet.has(
968 providerModelKey(model.instanceId, model.slug),1007 providerModelKey(model.instanceId, model.slug),
969 )}1008 )}
970 isSelected={modelKey === activeModelKey}1009 isSelected={
1010 selectedModelKeys !== undefined
1011 ? selectedModelKeySet.has(modelKey)
1012 : modelKey === activeModelKey
1013 }
1014 showSelection={selectedModelKeys !== undefined}
971 showProvider1015 showProvider
972 preferShortName={!isLocked}1016 preferShortName={!isLocked}
973 useTriggerLabel={false}1017 useTriggerLabel={false}
apps/web/src/components/chat/ProviderModelPicker.tsx+66-6
...@@ -34,6 +34,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -34,6 +34,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
34 */34 */
35 activeInstanceId: ProviderInstanceId;35 activeInstanceId: ProviderInstanceId;
36 model: string;36 model: string;
37 selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>;
38 onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void;
37 lockedProvider: ProviderDriverKind | null;39 lockedProvider: ProviderDriverKind | null;
38 lockedContinuationGroupKey?: string | null;40 lockedContinuationGroupKey?: string | null;
39 /** Instance entries rendered in the sidebar + used to resolve display name. */41 /** Instance entries rendered in the sidebar + used to resolve display name. */
...@@ -158,9 +160,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -158,9 +160,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
158 const shortcutLabel = props.keybindings160 const shortcutLabel = props.keybindings
159 ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle")161 ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle")
160 : null;162 : null;
163 const selectedEntries = props.selectedModels?.map((selection) => {
164 const entry = props.instanceEntries.find(
165 (candidate) => candidate.instanceId === selection.instanceId,
166 );
167 const model = resolveModelPickerSelectedModel({
168 driverKind: entry?.driverKind,
169 model: selection.model,
170 options: props.modelOptionsByInstance.get(selection.instanceId) ?? [],
171 });
172 return {
173 ...selection,
174 entry,
175 label: model
176 ? `${getTriggerDisplayModelName(model)}${model.isUnavailable ? " (Unavailable)" : ""}`
177 : selection.model,
178 };
179 });
180 const multipleLabel = selectedEntries
181 ? selectedEntries.length === 0
182 ? "Choose models"
183 : `${selectedEntries
184 .slice(0, 2)
185 .map((selection) => selection.label)
186 .join(", ")}${selectedEntries.length > 2 ? `, ${selectedEntries.length - 2} more` : ""}`
187 : undefined;
188 const allModelNames = selectedEntries
189 ? selectedEntries.map((selection) => selection.label).join(", ") || "Choose models"
190 : undefined;
161 const triggerTooltipContent = shortcutLabel191 const triggerTooltipContent = shortcutLabel
162 ? `${props.triggerLabel ?? triggerLabel} · ${shortcutLabel}`192 ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}`
163 : (props.triggerLabel ?? triggerLabel);193 : (props.triggerLabel ?? allModelNames ?? triggerLabel);
164194
165 return (195 return (
166 <Popover196 <Popover
...@@ -176,7 +206,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -176,7 +206,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
176 <PopoverTrigger206 <PopoverTrigger
177 render={207 render={
178 <ComposerControl208 <ComposerControl
179 aria-label={props.triggerAriaLabel}209 aria-label={props.triggerAriaLabel ?? allModelNames}
180 variant={props.triggerVariant ?? "ghost"}210 variant={props.triggerVariant ?? "ghost"}
181 size={size}211 size={size}
182 data-chat-provider-model-picker="true"212 data-chat-provider-model-picker="true"
...@@ -192,7 +222,29 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -192,7 +222,29 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
192 <span222 <span
193 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}223 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}
194 >224 >
195 {activeEntry && props.triggerLabel === undefined ? (225 {selectedEntries && props.triggerLabel === undefined ? (
226 <span className="flex shrink-0 items-center -space-x-1" aria-hidden="true">
227 {selectedEntries
228 .slice(0, 3)
229 .map((selection) =>
230 selection.entry ? (
231 <ProviderInstanceIcon
232 key={`${selection.instanceId}:${selection.model}`}
233 driverKind={selection.entry.driverKind}
234 displayName={selection.entry.displayName}
235 accentColor={selection.entry.accentColor}
236 className="size-4 rounded-full bg-[var(--chat-composer-glass-surface,var(--background))] ring-2 ring-[var(--chat-composer-glass-surface,var(--background))]"
237 iconClassName="size-4"
238 />
239 ) : null,
240 )}
241 {selectedEntries.length > 3 ? (
242 <span className="relative z-30 flex size-4 items-center justify-center rounded-full bg-[var(--chat-composer-glass-surface,var(--background))] text-[9px] ring-2 ring-[var(--chat-composer-glass-surface,var(--background))]">
243 +{selectedEntries.length - 3}
244 </span>
245 ) : null}
246 </span>
247 ) : activeEntry && props.triggerLabel === undefined ? (
196 <ProviderInstanceIcon248 <ProviderInstanceIcon
197 driverKind={activeEntry.driverKind}249 driverKind={activeEntry.driverKind}
198 displayName={activeEntry.displayName}250 displayName={activeEntry.displayName}
...@@ -216,11 +268,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -216,11 +268,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
216 />268 />
217 }269 }
218 >270 >
219 {props.triggerLabel ?? triggerTitle}271 {props.triggerLabel ?? multipleLabel ?? triggerTitle}
220 </TooltipTrigger>272 </TooltipTrigger>
221 <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup>273 <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup>
222 </Tooltip>274 </Tooltip>
223 {selectedModel?.isUnavailable && props.triggerLabel === undefined ? (275 {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? (
224 <Badge variant="outline" size="sm">276 <Badge variant="outline" size="sm">
225 Unavailable277 Unavailable
226 </Badge>278 </Badge>
...@@ -239,6 +291,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -239,6 +291,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
239 <ModelPickerContent291 <ModelPickerContent
240 activeInstanceId={activeInstanceId}292 activeInstanceId={activeInstanceId}
241 model={props.model}293 model={props.model}
294 {...(props.selectedModels !== undefined ? { selectedModels: props.selectedModels } : {})}
295 {...(props.onToggleModel
296 ? {
297 onToggleModel: (instanceId: ProviderInstanceId, model: string) => {
298 if (!props.disabled) props.onToggleModel?.(instanceId, model);
299 },
300 }
301 : {})}
242 lockedProvider={props.lockedProvider}302 lockedProvider={props.lockedProvider}
243 lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null}303 lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null}
244 instanceEntries={props.instanceEntries}304 instanceEntries={props.instanceEntries}
docs/user/thread-sidebar.md+5
...@@ -20,6 +20,11 @@ on Windows and Linux to start a new thread and immediately open another draft. T...@@ -20,6 +20,11 @@ on Windows and Linux to start a new thread and immediately open another draft. T
20next draft keeps the workspace mode and base branch you selected. With **New20next draft keeps the workspace mode and base branch you selected. With **New
21worktree**, each background submission creates its own worktree.21worktree**, each background submission creates its own worktree.
2222
23To send the same prompt to several models on web or desktop, **Shift-click** models
24in a new thread's model picker to add or remove them. A regular click returns to a
25single model. Choose a base branch and send. Each selection starts a separate thread
26and worktree while you stay in the new thread composer. This requires a Git project.
27
23## Pin and reorder threads28## Pin and reorder threads
2429
25Pin a thread from its menu to keep it above your active work.30Pin a thread from its menu to keep it above your active work.
packages/client-runtime/src/errors/orchestration.test.ts+28-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { OrchestrationDispatchCommandError } from "@t3tools/contracts";1import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { wasBootstrapThreadDeleted } from "./orchestration.ts";4import { wasBootstrapThreadDeleted, wasBootstrapThreadNotCreated } from "./orchestration.ts";
55
6describe("wasBootstrapThreadDeleted", () => {6describe("wasBootstrapThreadDeleted", () => {
7 it("accepts only a confirmed deleted bootstrap thread", () => {7 it("accepts only a confirmed deleted bootstrap thread", () => {
...@@ -21,3 +21,30 @@ describe("wasBootstrapThreadDeleted", () => {...@@ -21,3 +21,30 @@ describe("wasBootstrapThreadDeleted", () => {
21 expect(wasBootstrapThreadDeleted(new Error("connection lost"))).toBe(false);21 expect(wasBootstrapThreadDeleted(new Error("connection lost"))).toBe(false);
22 });22 });
23});23});
24
25describe("wasBootstrapThreadNotCreated", () => {
26 it("accepts only a confirmed never-created bootstrap thread", () => {
27 const notCreated = new OrchestrationDispatchCommandError({
28 message: "A separate worktree requires a base commit.",
29 bootstrapThreadDisposition: "not-created",
30 });
31 expect(wasBootstrapThreadNotCreated(notCreated)).toBe(true);
32 expect(wasBootstrapThreadDeleted(notCreated)).toBe(false);
33 expect(
34 wasBootstrapThreadNotCreated(
35 new OrchestrationDispatchCommandError({
36 message: "Failed to create worktree.",
37 bootstrapThreadDisposition: "deleted",
38 }),
39 ),
40 ).toBe(false);
41 expect(
42 wasBootstrapThreadNotCreated(
43 new OrchestrationDispatchCommandError({
44 message: "Failed to create worktree.",
45 }),
46 ),
47 ).toBe(false);
48 expect(wasBootstrapThreadNotCreated(new Error("connection lost"))).toBe(false);
49 });
50});
packages/client-runtime/src/errors/orchestration.ts+6
...@@ -8,3 +8,9 @@ export function wasBootstrapThreadDeleted(error: unknown): boolean {...@@ -8,3 +8,9 @@ export function wasBootstrapThreadDeleted(error: unknown): boolean {
8 isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "deleted"8 isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "deleted"
9 );9 );
10}10}
11
12export function wasBootstrapThreadNotCreated(error: unknown): boolean {
13 return (
14 isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "not-created"
15 );
16}
packages/contracts/src/environment.test.ts+10
...@@ -14,6 +14,16 @@ const descriptor = {...@@ -14,6 +14,16 @@ const descriptor = {
14} as const;14} as const;
1515
16describe("ExecutionEnvironmentDescriptor", () => {16describe("ExecutionEnvironmentDescriptor", () => {
17 it("requires an advertised required-worktree bootstrap capability", () => {
18 expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined();
19 expect(
20 decodeDescriptor({
21 ...descriptor,
22 capabilities: { ...descriptor.capabilities, requiredWorktreeBootstrap: true },
23 }).capabilities.requiredWorktreeBootstrap,
24 ).toBe(true);
25 });
26
17 it("treats a missing pull-request capability as unsupported under version skew", () => {27 it("treats a missing pull-request capability as unsupported under version skew", () => {
18 expect(decodeDescriptor(descriptor).capabilities.pullRequests).toBeUndefined();28 expect(decodeDescriptor(descriptor).capabilities.pullRequests).toBeUndefined();
19 });29 });
packages/contracts/src/environment.ts+2
...@@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
99 Absent on servers from before inline context shipped, which drop the records and forward99 Absent on servers from before inline context shipped, which drop the records and forward
100 the links as literal text -- so a client must serialize context the legacy way for them. */100 the links as literal text -- so a client must serialize context the legacy way for them. */
101 inlineMessageContext: Schema.optionalKey(Schema.Boolean),101 inlineMessageContext: Schema.optionalKey(Schema.Boolean),
102 /** Server rejects required worktrees instead of falling back to the project checkout. */
103 requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean),
102 /** Server understands thread.settle / thread.unsettle commands. Absent on104 /** Server understands thread.settle / thread.unsettle commands. Absent on
103 pre-settlement servers, so clients treat missing as unsupported and105 pre-settlement servers, so clients treat missing as unsupported and
104 never send the commands under version skew. */106 never send the commands under version skew. */
packages/contracts/src/orchestration.test.ts+14
...@@ -84,6 +84,18 @@ it.effect("decodes a dispatch error after its bootstrap thread was deleted", ()...@@ -84,6 +84,18 @@ it.effect("decodes a dispatch error after its bootstrap thread was deleted", ()
84 }),84 }),
85);85);
8686
87it.effect("decodes a dispatch error before its bootstrap thread was created", () =>
88 Effect.gen(function* () {
89 const error = yield* decodeDispatchCommandError({
90 _tag: "OrchestrationDispatchCommandError",
91 message: "A separate worktree requires a base commit.",
92 bootstrapThreadDisposition: "not-created",
93 });
94
95 assert.strictEqual(error.bootstrapThreadDisposition, "not-created");
96 }),
97);
98
87it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>99it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
88 Effect.gen(function* () {100 Effect.gen(function* () {
89 const parsed = yield* decodeTurnDiffInput({101 const parsed = yield* decodeTurnDiffInput({
...@@ -545,6 +557,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>...@@ -545,6 +557,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
545 baseBranch: "main",557 baseBranch: "main",
546 branch: "t3code/example",558 branch: "t3code/example",
547 startFromOrigin: true,559 startFromOrigin: true,
560 requireWorktree: true,
548 },561 },
549 runSetupScript: true,562 runSetupScript: true,
550 },563 },
...@@ -553,6 +566,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>...@@ -553,6 +566,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
553 assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1");566 assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1");
554 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main");567 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main");
555 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true);568 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true);
569 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.requireWorktree, true);
556 assert.strictEqual(parsed.bootstrap?.runSetupScript, true);570 assert.strictEqual(parsed.bootstrap?.runSetupScript, true);
557 }),571 }),
558);572);
packages/contracts/src/orchestration.ts+2-1
...@@ -1271,6 +1271,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({...@@ -1271,6 +1271,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({
1271 baseBranch: TrimmedNonEmptyString,1271 baseBranch: TrimmedNonEmptyString,
1272 branch: Schema.optional(TrimmedNonEmptyString),1272 branch: Schema.optional(TrimmedNonEmptyString),
1273 startFromOrigin: Schema.optional(Schema.Boolean),1273 startFromOrigin: Schema.optional(Schema.Boolean),
1274 requireWorktree: Schema.optional(Schema.Boolean),
1274});1275});
12751276
1276const ThreadTurnStartBootstrap = Schema.Struct({1277const ThreadTurnStartBootstrap = Schema.Struct({
...@@ -2381,7 +2382,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError<Orches...@@ -2381,7 +2382,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError<Orches
2381 {2382 {
2382 message: TrimmedNonEmptyString,2383 message: TrimmedNonEmptyString,
2383 cause: Schema.optional(Schema.Defect()),2384 cause: Schema.optional(Schema.Defect()),
2384 bootstrapThreadDisposition: Schema.optional(Schema.Literal("deleted")),2385 bootstrapThreadDisposition: Schema.optional(Schema.Literals(["deleted", "not-created"])),
2385 },2386 },
2386) {}2387) {}
23872388