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) => {
170170 expect(second.capabilities.attachmentUploads).toBe(true);
171171 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
172172 expect(second.capabilities.pullRequests).toBe(true);
173 expect(second.capabilities.requiredWorktreeBootstrap).toBe(true);
173174 expect(second.capabilities.usagePriceOverrides).toBe(true);
174175 expect(second.capabilities.threadActiveReorder).toBe(true);
175176 expect(second.capabilities.threadTitleRegeneration).toBe(true);
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
222222 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
223223 pullRequests: true,
224224 inlineMessageContext: true,
225 requiredWorktreeBootstrap: true,
225226 threadSettlement: true,
226227 threadAutoSettlement: true,
227228 storageCleanup: true,
apps/server/src/server.test.ts+96
......@@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1118411184 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1118511185 );
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
1118711283 it.effect("falls back to the project checkout when worktree mode targets a non-repository", () =>
1118811284 Effect.gen(function* () {
1118911285 const dispatchedCommands: Array<OrchestrationCommand> = [];
apps/server/src/ws.ts+12-3
......@@ -1369,6 +1369,12 @@ const makeWsRpcLayer = (
13691369 }
13701370
13711371 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 }
13721378 // Not a git repo, or the base has no commit: the thread runs in
13731379 // the project checkout instead. The card says so and moves on.
13741380 yield* track(
......@@ -1401,8 +1407,8 @@ const makeWsRpcLayer = (
14011407 // every delete for the prior incarnation committed before it.
14021408 // Drain through that event before setup or turn start can own
14031409 // terminals and provider sessions under the reused thread id.
1404 yield* threadDeletionReactor.drainThrough(created.sequence);
14051410 createdThread = true;
1411 yield* threadDeletionReactor.drainThrough(created.sequence);
14061412 // Persist the send now rather than with the turn: the thread is
14071413 // real from here on, so any client (or a reload) sees the message
14081414 // while the worktree is still being prepared. The turn start
......@@ -1606,13 +1612,16 @@ const makeWsRpcLayer = (
16061612 ),
16071613 onSuccess: (threadDeleted) =>
16081614 Effect.fail(
1609 threadDeleted
1615 threadDeleted ||
1616 (bootstrap?.createThread &&
1617 bootstrap.prepareWorktree?.requireWorktree === true &&
1618 !createdThread)
16101619 ? new OrchestrationDispatchCommandError({
16111620 message: dispatchError.message,
16121621 ...(dispatchError.cause !== undefined
16131622 ? { cause: dispatchError.cause }
16141623 : {}),
1615 bootstrapThreadDisposition: "deleted",
1624 bootstrapThreadDisposition: threadDeleted ? "deleted" : "not-created",
16161625 })
16171626 : dispatchError,
16181627 ),
apps/web/src/components/BranchToolbar.tsx+26-10
......@@ -66,6 +66,7 @@ export interface BranchToolbarHandle {
6666}
6767
6868interface BranchToolbarProps {
69 forceNewWorktree?: boolean;
6970 ref?: Ref<BranchToolbarHandle>;
7071 environmentId: EnvironmentId;
7172 threadId: ThreadId;
......@@ -89,6 +90,7 @@ interface BranchToolbarProps {
8990}
9091
9192interface MobileRunContextSelectorProps {
93 forceNewWorktree: boolean;
9294 autoEnvironmentLabel?: string | undefined;
9395 onAutoEnvironment?: (() => void) | undefined;
9496 envLocked: boolean;
......@@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps {
106108}
107109
108110const 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
478483export 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+5-1
......@@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle {
7979}
8080
8181interface 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
101102export 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+14-4
......@@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
2222const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree";
2323
2424interface BranchToolbarEnvModeSelectorProps {
25 forceNewWorktree?: boolean;
2526 envLocked: boolean;
2627 effectiveEnvMode: EnvMode;
2728 activeWorktreePath: string | null;
......@@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps {
3132}
3233
3334export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({
35 forceNewWorktree = false,
3436 envLocked,
3537 effectiveEnvMode,
3638 activeWorktreePath,
......@@ -51,7 +53,7 @@ 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
......@@ -59,7 +61,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
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 }
apps/web/src/components/ChatView.tsx+389-12
......@@ -46,7 +46,10 @@ import {
4646 type WorktreeSetupSnapshot,
4747} from "@t3tools/contracts";
4848import { 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";
5053import { readPastedComposerContext } from "./composerInlineTokenPaste";
5154import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
5255import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
......@@ -82,9 +85,11 @@ import {
8285} from "@t3tools/shared/terminalLabels";
8386import { Debouncer } from "@tanstack/react-pacer";
8487import { useAtomValue } from "@effect/atom-react";
88import { Atom } from "effect/unstable/reactivity";
8589import {
8690 lazy,
8791 memo,
92 type SetStateAction,
8893 Suspense,
8994 useCallback,
9095 useEffect,
......@@ -265,6 +270,10 @@ import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
265270import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
266271import { useThreadActions } from "../hooks/useThreadActions";
267272import { resolveAppModelSelectionForInstance } from "../modelSelection";
273import {
274 getComposerPromptInjectionState,
275 getComposerProviderState,
276} from "./chat/composerProviderState";
268277import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
269278import { isPreviewFocused } from "../lib/previewFocus";
270279import { getTerminalFocusOwner } from "../lib/terminalFocus";
......@@ -443,6 +452,7 @@ import {
443452 resolveBackgroundDraftWorkspaceOptions,
444453 resolveComposerInteractionMode,
445454 resolveComposerProviderSelection,
455 getAntigravitySendBlockReason,
446456 resolveDraftHeroState,
447457 findRecordedWorktreeSetup,
448458 resolveVisibleWorktreeSetup,
......@@ -698,6 +708,14 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null {
698708 return text.length > 0 ? text : null;
699709}
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
701719function formatOutgoingPrompt(params: {
702720 provider: ProviderDriverKind;
703721 model: string | null;
......@@ -1771,7 +1789,23 @@ export default function ChatView(props: ChatViewProps) {
17711789 const [timelineOverflows, setTimelineOverflows] = useState(false);
17721790 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
17731791 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;
17751809 const environmentUnavailableSendToastSlotRef = useRef(0);
17761810 const feedbackUploadsInFlightRef = useRef(new Set<string>());
17771811 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});
......@@ -7302,6 +7336,31 @@ export default function ChatView(props: ChatViewProps) {
73027336 notifyDirectAnnotationAttached();
73037337 return;
73047338 }
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 }
73057364 const {
73067365 images: sendContextImages,
73077366 files: composerFiles,
......@@ -7379,7 +7438,7 @@ export default function ChatView(props: ChatViewProps) {
73797438 composerReviewComments.length === 0
73807439 ? parseCodexFeedbackCommand(trimmed)
73817440 : null;
7382 if (feedbackCommand && !queuedMessage) {
7441 if (feedbackCommand && !queuedMessage && multipleModelSelections === null) {
73837442 if (!isServerThread || activeThread.session === null) {
73847443 toastManager.add(
73857444 stackedThreadToast({
......@@ -7502,7 +7561,7 @@ export default function ChatView(props: ChatViewProps) {
75027561 composerReviewComments.length === 0
75037562 ? parseStandaloneComposerSlashCommand(trimmed)
75047563 : null;
7505 if (standaloneSlashCommand && !queuedMessage) {
7564 if (standaloneSlashCommand && !queuedMessage && multipleModelSelections === null) {
75067565 handleInteractionModeChange(standaloneSlashCommand);
75077566 promptRef.current = "";
75087567 clearComposerDraftContent(composerDraftTarget);
......@@ -7652,7 +7711,53 @@ export default function ChatView(props: ChatViewProps) {
76527711 };
76537712 };
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
76557759 sendInFlightRef.current = true;
7760 const sendGeneration = ++composerSendGenerationRef.current;
76567761 // Every early return above leaves a queued message in the queue for a
76577762 // later retry. From here on a failure hands it back to the composer.
76587763 if (queuedMessage) {
......@@ -7728,7 +7833,9 @@ export default function ChatView(props: ChatViewProps) {
77287833 }
77297834
77307835 const resolvedSubmissionIntent =
7731 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";
7836 (multipleModelSelections !== null || submissionIntent === "background") && isLocalDraftThread
7837 ? "background"
7838 : "foreground";
77327839 if (
77337840 shouldDockDraftHeroForSubmission({
77347841 isDraftHeroState,
......@@ -7763,7 +7870,7 @@ export default function ChatView(props: ChatViewProps) {
77637870 return;
77647871 }
77657872 beginLocalDispatch({
7766 preparingWorktree: Boolean(baseBranchForWorktree),
7873 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
77677874 submissionIntent: resolvedSubmissionIntent,
77687875 });
77697876
......@@ -7792,6 +7899,267 @@ export default function ChatView(props: ChatViewProps) {
77927899 };
77937900 }),
77947901 );
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 }
77958163 const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) =>
77968164 attachment.type === "image"
77978165 ? {
......@@ -8868,7 +9236,7 @@ export default function ChatView(props: ChatViewProps) {
88689236 );
88699237
88709238 const onProviderModelSelect = useCallback(
8871 (instanceId: ProviderInstanceId, model: string) => {
9239 (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => {
88729240 if (!activeThread) return;
88739241 // Look up the configured instance so model normalization and custom
88749242 // model lookup stay scoped to that exact instance. Unknown instance ids
......@@ -8880,7 +9248,7 @@ export default function ChatView(props: ChatViewProps) {
88809248 resolvedDriverKind !== null &&
88819249 resolvedDriverKind !== lockedProvider
88829250 ) {
8883 scheduleComposerFocus();
9251 if (options?.focusComposer !== false) scheduleComposerFocus();
88849252 return;
88859253 }
88869254 if (lockedProvider !== null && activeThread.session?.providerInstanceId) {
......@@ -8892,7 +9260,7 @@ export default function ChatView(props: ChatViewProps) {
88929260 entry?.continuation?.groupKey &&
88939261 currentEntry.continuation.groupKey !== entry.continuation.groupKey
88949262 ) {
8895 scheduleComposerFocus();
9263 if (options?.focusComposer !== false) scheduleComposerFocus();
88969264 return;
88979265 }
88989266 }
......@@ -8903,7 +9271,7 @@ export default function ChatView(props: ChatViewProps) {
89039271 model,
89049272 );
89059273 if (!resolvedModel) {
8906 scheduleComposerFocus();
9274 if (options?.focusComposer !== false) scheduleComposerFocus();
89079275 return;
89089276 }
89099277 const nextModelSelection: ModelSelection = {
......@@ -8923,7 +9291,7 @@ export default function ChatView(props: ChatViewProps) {
89239291 title: modelChangeBlockReason.title,
89249292 description: modelChangeBlockReason.description,
89259293 });
8926 scheduleComposerFocus();
9294 if (options?.focusComposer !== false) scheduleComposerFocus();
89279295 return;
89289296 }
89299297 setComposerDraftModelSelection(
......@@ -8932,7 +9300,7 @@ export default function ChatView(props: ChatViewProps) {
89329300 { explicit: true },
89339301 );
89349302 setStickyComposerModelSelection(nextModelSelection);
8935 scheduleComposerFocus();
9303 if (options?.focusComposer !== false) scheduleComposerFocus();
89369304 },
89379305 [
89389306 activeThread,
......@@ -8946,6 +9314,7 @@ export default function ChatView(props: ChatViewProps) {
89469314 );
89479315 const onEnvModeChange = useCallback(
89489316 (mode: DraftThreadEnvMode) => {
9317 if (multipleModelSelections !== null) return;
89499318 if (canOverrideServerThreadEnvMode) {
89509319 setPendingServerThreadEnvMode(mode);
89519320 scheduleComposerFocus();
......@@ -8968,6 +9337,7 @@ export default function ChatView(props: ChatViewProps) {
89689337 composerDraftTarget,
89699338 draftThread?.worktreePath,
89709339 isLocalDraftThread,
9340 multipleModelSelections,
89719341 activeProjectSettings.settings.newWorktreesStartFromOrigin,
89729342 setPendingServerThreadEnvMode,
89739343 scheduleComposerFocus,
......@@ -9632,6 +10002,12 @@ export default function ChatView(props: ChatViewProps) {
963210002 <ComposerSurface.Host>
963310003 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
963410004 <ChatComposer
10005 multipleModelSelections={multipleModelSelections}
10006 supportsMultipleModels={
10007 serverConfig?.environment.capabilities.requiredWorktreeBootstrap ===
10008 true
10009 }
10010 onMultipleModelSelectionsChange={setMultipleModelSelections}
963510011 composerRef={composerRef}
963610012 composerDraftTarget={composerDraftTarget}
963710013 environmentId={environmentId}
......@@ -9769,6 +10145,7 @@ export default function ChatView(props: ChatViewProps) {
976910145 {mountComposerContextStrip && (
977010146 <div className="pointer-events-auto">
977110147 <BranchToolbar
10148 forceNewWorktree={multipleModelSelections !== null}
977210149 ref={branchToolbarRef}
977310150 environmentId={activeThread.environmentId}
977410151 threadId={activeThread.id}
apps/web/src/components/chat/ChatComposer.tsx+77-6
......@@ -245,6 +245,7 @@ import {
245245import { useEnvironmentQuery } from "~/state/query";
246246import { useDebouncedValue } from "~/state/queries";
247247import { ProviderModelPicker } from "./ProviderModelPicker";
248import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
248249import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
249250import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
250251import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
......@@ -1267,6 +1268,7 @@ export interface ChatComposerHandle {
12671268 selectedPromptEffort: string | null;
12681269 selectedModelOptionsForDispatch: unknown;
12691270 selectedModelSelection: ModelSelection;
1271 multipleModelSelections: ReadonlyArray<ModelSelection> | null;
12701272 providerAvailable: boolean;
12711273 selectedProvider: ProviderDriverKind;
12721274 selectedModel: string;
......@@ -1276,6 +1278,7 @@ export interface ChatComposerHandle {
12761278 };
12771279 /** Validate the fully composed text immediately before a provider turn starts. */
12781280 validateProviderInput: (providerInput: string) => boolean;
1281 setMultipleModelSelections: (selections: ReadonlyArray<ModelSelection>) => void;
12791282}
12801283
12811284// --------------------------------------------------------------------------
......@@ -1292,6 +1295,11 @@ export interface ChatComposerProps {
12921295 routeKind: "server" | "draft";
12931296 routeThreadRef: ScopedThreadRef;
12941297 draftId: DraftId | null;
1298 multipleModelSelections: ReadonlyArray<ModelSelection> | null;
1299 supportsMultipleModels: boolean;
1300 onMultipleModelSelectionsChange: React.Dispatch<
1301 React.SetStateAction<ReadonlyArray<ModelSelection> | null>
1302 >;
12951303
12961304 // Thread context
12971305 activeThreadId: ThreadId | null;
......@@ -1420,7 +1428,11 @@ export interface ChatComposerProps {
14201428 cursorAdjacentToMention: boolean,
14211429 ) => void;
14221430
1423 onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void;
1431 onProviderModelSelect: (
1432 instanceId: ProviderInstanceId,
1433 model: string,
1434 options?: { focusComposer?: boolean },
1435 ) => void;
14241436 onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
14251437 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;
14261438 toggleInteractionMode: () => void;
......@@ -1449,6 +1461,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
14491461 routeKind,
14501462 routeThreadRef,
14511463 draftId,
1464 multipleModelSelections,
1465 supportsMultipleModels,
1466 onMultipleModelSelectionsChange: setMultipleModelSelections,
14521467 activeThreadId,
14531468 activeThreadEnvironmentId: _activeThreadEnvironmentId,
14541469 activeThread,
......@@ -1847,7 +1862,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18471862 );
18481863 const selectedInstanceId =
18491864 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
1850 const noProviderAvailable = selectedProviderEntry === undefined;
1865 const noProviderAvailable =
1866 selectedProviderEntry === undefined && multipleModelSelections === null;
18511867 // Before the catalog arrives, every thread resolves to "no provider". Send
18521868 // stays blocked either way; only the chrome waits, keeping the picker with
18531869 // 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)
18831899 );
18841900 const sendDisabledReason =
18851901 externalSendDisabledReason ??
1902 (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ??
18861903 (activePendingProgress
18871904 ? attachmentBlockReason
1888 : (attachmentBlockReason ?? providerSendBlockReason));
1905 : (attachmentBlockReason ??
1906 (multipleModelSelections === null ? providerSendBlockReason : null)));
18891907 const isSendDisabled = sendDisabledReason !== null;
18901908 const selectedProviderStatus = useMemo(
18911909 () => selectedProviderEntry?.snapshot ?? null,
......@@ -4943,7 +4961,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49434961 ) : null}
49444962 <ProviderModelPicker
49454963 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 : {})}
49475000 activeInstanceId={
49485001 providerCatalogPending
49495002 ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId)
......@@ -4983,7 +5036,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49835036 : {})}
49845037 onOpenChange={setIsComposerModelPickerOpen}
49855038 getModelDisabledReason={getModelDisabledReason}
4986 onInstanceModelChange={onProviderModelSelect}
5039 onInstanceModelChange={(instanceId, model) => {
5040 setMultipleModelSelections(null);
5041 onProviderModelSelect(instanceId, model);
5042 }}
49875043 onOpenProviderSetup={onOpenProviderSetup}
49885044 />
49895045
......@@ -5902,13 +5958,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
59025958 selectedPromptEffort,
59035959 selectedModelOptionsForDispatch,
59045960 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),
59065973 selectedProvider,
59075974 selectedModel,
59085975 selectedProviderModels,
59095976 interactionMode,
59105977 interactionModeEnabled: planModeUiEnabled,
59115978 }),
5979 setMultipleModelSelections,
59125980 validateProviderInput: (providerInput: string) => {
59135981 const validationMessage = getComposerSubmissionValidationMessage({
59145982 prompt: promptRef.current,
......@@ -5951,6 +6019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
59516019 selectedModel,
59526020 selectedModelOptionsForDispatch,
59536021 selectedModelSelection,
6022 multipleModelSelections,
6023 setMultipleModelSelections,
6024 routeKind,
59546025 noProviderAvailable,
59556026 providerSendBlockReason,
59566027 selectedPromptEffort,
apps/web/src/components/chat/ModelListRow.tsx+5-1
......@@ -1,6 +1,6 @@
11import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
22import { memo } from "react";
3import { StarIcon } from "lucide-react";
3import { CheckIcon, StarIcon } from "lucide-react";
44import {
55 getDisplayModelName,
66 getTriggerDisplayModelLabel,
......@@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
3131 providerAccentColor?: string | undefined;
3232 isFavorite: boolean;
3333 isSelected: boolean;
34 showSelection?: boolean;
3435 showProvider: boolean;
3536 preferShortName?: boolean;
3637 useTriggerLabel?: boolean;
......@@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: {
9495 </div>
9596
9697 <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}
97101 {props.jumpLabel ? (
98102 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">{props.jumpLabel}</Kbd>
99103 ) : null}
apps/web/src/components/chat/ModelPickerContent.tsx+55-11
......@@ -151,6 +151,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
151151 /** The instance currently selected in the composer (combobox "value"). */
152152 activeInstanceId: ProviderInstanceId;
153153 model: string;
154 selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>;
155 onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void;
154156 /**
155157 * When set, the picker is locked to the given driver kind — typically
156158 * because the user is editing a previously-sent message and can't change
......@@ -186,6 +188,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
186188 instanceEntries,
187189 getModelDisabledReason,
188190 onInstanceModelChange,
191 onToggleModel,
189192 } = props;
190193 const [searchQuery, setSearchQuery] = useState("");
191194 const [showTopScrollFade, setShowTopScrollFade] = useState(false);
......@@ -207,6 +210,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
207210 const activeModelKey = activeModelSlug
208211 ? modelPickerModelKey(props.activeInstanceId, activeModelSlug)
209212 : 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 );
210230 const activeInstanceHasSelectableUnavailableModel =
211231 activeEntry !== undefined &&
212232 (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>
......@@ -575,7 +595,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
575595 }, []);
576596
577597 const handleModelSelect = useCallback(
578 (modelSlug: string, instanceId: ProviderInstanceId) => {
598 (modelSlug: string, instanceId: ProviderInstanceId, additive = false) => {
579599 if (getModelDisabledReason?.(instanceId, modelSlug)) {
580600 return;
581601 }
......@@ -592,10 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
592612 // normalization rules, so pass the driver kind here.
593613 const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options);
594614 if (resolvedModel) {
595 onInstanceModelChange(instanceId, resolvedModel);
615 if (additive && onToggleModel) {
616 onToggleModel(instanceId, resolvedModel);
617 } else {
618 onInstanceModelChange(instanceId, resolvedModel);
619 }
596620 }
597621 },
598 [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange],
622 [
623 entryByInstanceId,
624 getModelDisabledReason,
625 modelOptionsByInstance,
626 onInstanceModelChange,
627 onToggleModel,
628 ],
599629 );
600630
601631 const toggleFavorite = useCallback(
......@@ -701,8 +731,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
701731 return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;
702732 }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);
703733 const modelListExtraData = useMemo(
704 () => ({ favoritesSet, modelJumpLabelByKey }),
705 [favoritesSet, modelJumpLabelByKey],
734 () => ({ favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet }),
735 [favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet],
706736 );
707737
708738 useEffect(() => {
......@@ -804,7 +834,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
804834 )}
805835
806836 {/* Main content area */}
807 <Combobox
837 <Combobox<string, boolean>
808838 inline
809839 items={allItemKeys}
810840 filteredItems={filteredItemKeys}
......@@ -812,7 +842,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
812842 autoHighlight
813843 open
814844 virtualized
815 value={activeModelKey}
845 multiple={onToggleModel !== undefined}
846 value={onToggleModel ? [...selectedModelKeySet] : activeModelKey}
816847 onItemHighlighted={(modelKey, eventDetails) => {
817848 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
818849 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {
......@@ -822,7 +853,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
822853 });
823854 }
824855 }}
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;
826861 if (typeof modelKey !== "string") {
827862 return;
828863 }
......@@ -833,7 +868,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
833868 }
834869 const model = parseModelPickerModelKey(modelKey);
835870 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 );
837876 }
838877 }}
839878 >
......@@ -901,7 +940,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
901940 }
902941 const model = parseModelPickerModelKey(highlightedModelKeyRef.current);
903942 if (model) {
904 handleModelSelect(model.slug, model.instanceId);
943 handleModelSelect(model.slug, model.instanceId, e.shiftKey);
905944 }
906945 return;
907946 }
......@@ -967,7 +1006,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
9671006 isFavorite={favoritesSet.has(
9681007 providerModelKey(model.instanceId, model.slug),
9691008 )}
970 isSelected={modelKey === activeModelKey}
1009 isSelected={
1010 selectedModelKeys !== undefined
1011 ? selectedModelKeySet.has(modelKey)
1012 : modelKey === activeModelKey
1013 }
1014 showSelection={selectedModelKeys !== undefined}
9711015 showProvider
9721016 preferShortName={!isLocked}
9731017 useTriggerLabel={false}
apps/web/src/components/chat/ProviderModelPicker.tsx+66-6
......@@ -34,6 +34,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
3434 */
3535 activeInstanceId: ProviderInstanceId;
3636 model: string;
37 selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>;
38 onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void;
3739 lockedProvider: ProviderDriverKind | null;
3840 lockedContinuationGroupKey?: string | null;
3941 /** Instance entries rendered in the sidebar + used to resolve display name. */
......@@ -158,9 +160,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
158160 const shortcutLabel = props.keybindings
159161 ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle")
160162 : 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;
161191 const triggerTooltipContent = shortcutLabel
162 ? `${props.triggerLabel ?? triggerLabel} · ${shortcutLabel}`
163 : (props.triggerLabel ?? triggerLabel);
192 ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}`
193 : (props.triggerLabel ?? allModelNames ?? triggerLabel);
164194
165195 return (
166196 <Popover
......@@ -176,7 +206,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
176206 <PopoverTrigger
177207 render={
178208 <ComposerControl
179 aria-label={props.triggerAriaLabel}
209 aria-label={props.triggerAriaLabel ?? allModelNames}
180210 variant={props.triggerVariant ?? "ghost"}
181211 size={size}
182212 data-chat-provider-model-picker="true"
......@@ -192,7 +222,29 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
192222 <span
193223 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}
194224 >
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 ? (
196248 <ProviderInstanceIcon
197249 driverKind={activeEntry.driverKind}
198250 displayName={activeEntry.displayName}
......@@ -216,11 +268,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
216268 />
217269 }
218270 >
219 {props.triggerLabel ?? triggerTitle}
271 {props.triggerLabel ?? multipleLabel ?? triggerTitle}
220272 </TooltipTrigger>
221273 <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup>
222274 </Tooltip>
223 {selectedModel?.isUnavailable && props.triggerLabel === undefined ? (
275 {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? (
224276 <Badge variant="outline" size="sm">
225277 Unavailable
226278 </Badge>
......@@ -239,6 +291,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
239291 <ModelPickerContent
240292 activeInstanceId={activeInstanceId}
241293 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 : {})}
242302 lockedProvider={props.lockedProvider}
243303 lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null}
244304 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
2020next draft keeps the workspace mode and base branch you selected. With **New
2121worktree**, 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
2328## Pin and reorder threads
2429
2530Pin 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 @@
11import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
4import { wasBootstrapThreadDeleted } from "./orchestration.ts";
4import { wasBootstrapThreadDeleted, wasBootstrapThreadNotCreated } from "./orchestration.ts";
55
66describe("wasBootstrapThreadDeleted", () => {
77 it("accepts only a confirmed deleted bootstrap thread", () => {
......@@ -21,3 +21,30 @@ describe("wasBootstrapThreadDeleted", () => {
2121 expect(wasBootstrapThreadDeleted(new Error("connection lost"))).toBe(false);
2222 });
2323});
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 {
88 isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "deleted"
99 );
1010}
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 = {
1414} as const;
1515
1616describe("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
1727 it("treats a missing pull-request capability as unsupported under version skew", () => {
1828 expect(decodeDescriptor(descriptor).capabilities.pullRequests).toBeUndefined();
1929 });
packages/contracts/src/environment.ts+2
......@@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
9999 Absent on servers from before inline context shipped, which drop the records and forward
100100 the links as literal text -- so a client must serialize context the legacy way for them. */
101101 inlineMessageContext: Schema.optionalKey(Schema.Boolean),
102 /** Server rejects required worktrees instead of falling back to the project checkout. */
103 requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean),
102104 /** Server understands thread.settle / thread.unsettle commands. Absent on
103105 pre-settlement servers, so clients treat missing as unsupported and
104106 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", ()
8484 }),
8585);
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
8799it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
88100 Effect.gen(function* () {
89101 const parsed = yield* decodeTurnDiffInput({
......@@ -545,6 +557,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
545557 baseBranch: "main",
546558 branch: "t3code/example",
547559 startFromOrigin: true,
560 requireWorktree: true,
548561 },
549562 runSetupScript: true,
550563 },
......@@ -553,6 +566,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () =>
553566 assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1");
554567 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main");
555568 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true);
569 assert.strictEqual(parsed.bootstrap?.prepareWorktree?.requireWorktree, true);
556570 assert.strictEqual(parsed.bootstrap?.runSetupScript, true);
557571 }),
558572);
packages/contracts/src/orchestration.ts+2-1
......@@ -1271,6 +1271,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({
12711271 baseBranch: TrimmedNonEmptyString,
12721272 branch: Schema.optional(TrimmedNonEmptyString),
12731273 startFromOrigin: Schema.optional(Schema.Boolean),
1274 requireWorktree: Schema.optional(Schema.Boolean),
12741275});
12751276
12761277const ThreadTurnStartBootstrap = Schema.Struct({
......@@ -2381,7 +2382,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError<Orches
23812382 {
23822383 message: TrimmedNonEmptyString,
23832384 cause: Schema.optional(Schema.Defect()),
2384 bootstrapThreadDisposition: Schema.optional(Schema.Literal("deleted")),
2385 bootstrapThreadDisposition: Schema.optional(Schema.Literals(["deleted", "not-created"])),
23852386 },
23862387) {}
23872388