diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index f2299cdd599c..b8cb84349472 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -170,6 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); + expect(second.capabilities.requiredWorktreeBootstrap).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 64d8dfab1733..31339dd14edd 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, inlineMessageContext: true, + requiredWorktreeBootstrap: true, threadSettlement: true, threadAutoSettlement: true, threadRestartContinuation: true, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2a16bbfe0074..27522c47cf2d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect.each([ + { caseName: "a non-repository", isRepository: false, failFetch: false }, + { caseName: "a base without a commit", isRepository: true, failFetch: false }, + { caseName: "a fetch failure", isRepository: true, failFetch: true }, + ])( + "rejects required worktree bootstrap before creating a thread for $caseName", + ({ isRepository, failFetch }) => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("createWorktree must not run before a valid base is found")), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(isRepository), + }, + gitVcsDriver: { + execute: () => + Effect.succeed({ + ...SUCCESSFUL_GIT_EXECUTION, + exitCode: ChildProcessSpawner.ExitCode(128), + stderr: "fatal: Needed a single revision", + }), + remoteExists: () => Effect.succeed(true), + fetchRemote: () => Effect.die(new Error("fetch failed before thread creation")), + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-required-worktree"), + threadId: ThreadId.make("thread-required-worktree"), + message: { + messageId: MessageId.make("msg-required-worktree"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + requireWorktree: true, + startFromOrigin: failFetch, + }, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "not-created"); + assert.include( + result.failure.message, + failFetch ? "fetch failed" : "separate worktree requires", + ); + assert.equal(createWorktree.mock.calls.length, 0); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.activity.append"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8dfd33ea4b1c..b7f3bd7a533d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1369,6 +1369,12 @@ const makeWsRpcLayer = ( } if (prepareWorktree && !shouldPrepareWorktree) { + if (prepareWorktree.requireWorktree) { + return yield* new OrchestrationDispatchCommandError({ + message: + "A separate worktree requires a Git repository and a base branch with a commit.", + }); + } // Not a git repo, or the base has no commit: the thread runs in // the project checkout instead. The card says so and moves on. yield* track( @@ -1401,8 +1407,8 @@ const makeWsRpcLayer = ( // every delete for the prior incarnation committed before it. // Drain through that event before setup or turn start can own // terminals and provider sessions under the reused thread id. - yield* threadDeletionReactor.drainThrough(created.sequence); createdThread = true; + yield* threadDeletionReactor.drainThrough(created.sequence); // Persist the send now rather than with the turn: the thread is // real from here on, so any client (or a reload) sees the message // while the worktree is still being prepared. The turn start @@ -1606,13 +1612,16 @@ const makeWsRpcLayer = ( ), onSuccess: (threadDeleted) => Effect.fail( - threadDeleted + threadDeleted || + (bootstrap?.createThread && + bootstrap.prepareWorktree?.requireWorktree === true && + !createdThread) ? new OrchestrationDispatchCommandError({ message: dispatchError.message, ...(dispatchError.cause !== undefined ? { cause: dispatchError.cause } : {}), - bootstrapThreadDisposition: "deleted", + bootstrapThreadDisposition: threadDeleted ? "deleted" : "not-created", }) : dispatchError, ), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b2840cfadd0e..3330f40a090f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -66,6 +66,7 @@ export interface BranchToolbarHandle { } interface BranchToolbarProps { + forceNewWorktree?: boolean; ref?: Ref; environmentId: EnvironmentId; threadId: ThreadId; @@ -89,6 +90,7 @@ interface BranchToolbarProps { } interface MobileRunContextSelectorProps { + forceNewWorktree: boolean; autoEnvironmentLabel?: string | undefined; onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; @@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + forceNewWorktree, autoEnvironmentLabel, onAutoEnvironment, envLocked, @@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : activeWorktreePath ? FolderGitIcon : FolderIcon; - const workspaceLabel = envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath) - : effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") - : resolveCurrentWorkspaceLabel(activeWorktreePath); + const workspaceLabel = forceNewWorktree + ? resolveEnvModeLabel("worktree") + : envModeLocked + ? resolveLockedWorkspaceLabel(activeWorktreePath) + : effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; const workspaceIcon = ( @@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ onEnvModeChange(value as EnvMode); }} > - + {activeWorktreePath ? ( @@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { } export const BranchToolbar = memo(function BranchToolbar({ + forceNewWorktree = false, ref, environmentId, threadId, @@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({ : null; const activeProject = useProject(activeProjectRef); const hasActiveThread = serverThread !== null || draftThread !== null; - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeWorktreePath = forceNewWorktree + ? null + : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const effectiveEnvMode = - effectiveEnvModeOverride ?? + (forceNewWorktree ? "worktree" : effectiveEnvModeOverride) ?? resolveEffectiveEnvMode({ activeWorktreePath, hasServerThread: serverThread !== null, @@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({ // "Previous worktree" hops a draft into the most recently active worktree // of this project — the "keep going where I just was" follow-up flow. Only // drafts can hop; started server threads have their workspace pinned. - const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; + const canUsePreviousWorktree = + draftThread !== null && serverThread === null && !envModeLocked && !forceNewWorktree; const projectRefsForWorktreeLookup = useMemo( () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), [canUsePreviousWorktree, activeProjectRef], @@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {showGitControls ? (
; className?: string; environmentId: EnvironmentId; @@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string { } export function BranchToolbarBranchSelector({ + forceNewWorktree = false, ref, className, environmentId, @@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({ activeThreadBranchOverride !== undefined ? activeThreadBranchOverride : (serverThread?.branch ?? draftThread?.branch ?? null); - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeWorktreePath = forceNewWorktree + ? null + : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index c1c3487dbadc..0ec794b2970c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; interface BranchToolbarEnvModeSelectorProps { + forceNewWorktree?: boolean; envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps { } export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ + forceNewWorktree = false, envLocked, effectiveEnvMode, activeWorktreePath, @@ -51,7 +53,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], ); - if (envLocked) { + if (envLocked || forceNewWorktree) { return ( - {activeWorktreePath ? ( + {forceNewWorktree ? ( + + ) : activeWorktreePath ? ( ) : ( @@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe data-composer-label-motion className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" > - {resolveLockedWorkspaceLabel(activeWorktreePath)} + {forceNewWorktree + ? resolveEnvModeLabel("worktree") + : resolveLockedWorkspaceLabel(activeWorktreePath)} - {resolveLockedWorkspaceLabel(activeWorktreePath)} + + {forceNewWorktree + ? "Each model starts in its own worktree." + : resolveLockedWorkspaceLabel(activeWorktreePath)} + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec17751ce725..ebfc159e19de 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,7 +46,10 @@ import { type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; -import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; +import { + wasBootstrapThreadDeleted, + wasBootstrapThreadNotCreated, +} from "@t3tools/client-runtime/errors"; import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; @@ -82,9 +85,11 @@ import { } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; import { lazy, memo, + type SetStateAction, Suspense, useCallback, useEffect, @@ -264,6 +269,10 @@ import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; +import { + getComposerPromptInjectionState, + getComposerProviderState, +} from "./chat/composerProviderState"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -442,6 +451,7 @@ import { resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, + getAntigravitySendBlockReason, resolveDraftHeroState, findRecordedWorktreeSetup, resolveVisibleWorktreeSetup, @@ -697,6 +707,14 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null { return text.length > 0 ? text : null; } +const draftFanoutStateAtom = Atom.family((_routeKey: string) => + Atom.make({ + selections: null as ReadonlyArray | null, + sendInFlight: { current: false }, + uncertainSubmissions: { current: new Map() }, + }).pipe(Atom.keepAlive), +); + function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -1770,7 +1788,23 @@ export default function ChatView(props: ChatViewProps) { const [timelineOverflows, setTimelineOverflows] = useState(false); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); - const sendInFlightRef = useRef(false); + const fanoutStateAtom = draftFanoutStateAtom(routeThreadKey); + const fanoutState = useAtomValue(fanoutStateAtom); + const sendInFlightRef = fanoutState.sendInFlight; + const composerSendGenerationRef = useRef(0); + const multipleModelSelections = fanoutState.selections; + const setMultipleModelSelections = useCallback( + (selections: SetStateAction | null>) => { + appAtomRegistry.update(fanoutStateAtom, (current) => ({ + ...current, + selections: typeof selections === "function" ? selections(current.selections) : selections, + })); + }, + [fanoutStateAtom], + ); + const multipleModelSelectionsRef = useRef(multipleModelSelections); + multipleModelSelectionsRef.current = multipleModelSelections; + const uncertainMultipleSubmissionsRef = fanoutState.uncertainSubmissions; const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); @@ -7310,6 +7344,31 @@ export default function ChatView(props: ChatViewProps) { notifyDirectAnnotationAttached(); return; } + const multipleModelSelections = queuedMessage ? null : sendCtx.multipleModelSelections; + if ( + multipleModelSelections !== null && + serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true + ) { + setThreadError(activeThread.id, "Update this server before starting multiple models."); + return; + } + if ( + multipleModelSelections !== null && + (!isLocalDraftThread || + !isGitRepo || + !activeThreadBranch || + multipleModelSelections.length === 0) + ) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Choose models and a base branch", + description: + "Multiple models need a new thread in a Git project. Each gets its own worktree.", + }), + ); + return; + } const { images: sendContextImages, files: composerFiles, @@ -7387,7 +7446,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseCodexFeedbackCommand(trimmed) : null; - if (feedbackCommand && !queuedMessage) { + if (feedbackCommand && !queuedMessage && multipleModelSelections === null) { if (!isServerThread || activeThread.session === null) { toastManager.add( stackedThreadToast({ @@ -7510,7 +7569,7 @@ export default function ChatView(props: ChatViewProps) { composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; - if (standaloneSlashCommand && !queuedMessage) { + if (standaloneSlashCommand && !queuedMessage && multipleModelSelections === null) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -7660,7 +7719,53 @@ export default function ChatView(props: ChatViewProps) { }; }; + const multipleTargets = []; + for (const selection of multipleModelSelections ?? []) { + const provider = providerInstanceEntries.find( + (entry) => entry.instanceId === selection.instanceId, + ); + if (!provider?.enabled || !provider.isAvailable || provider.status !== "ready") { + setThreadError(threadIdForSend, `Provider for ${selection.model} is unavailable.`); + return; + } + const providerBlockReason = getAntigravitySendBlockReason(provider.snapshot, selection.model); + if (providerBlockReason) { + setThreadError(threadIdForSend, providerBlockReason); + return; + } + const providerState = getComposerProviderState({ + provider: provider.driverKind, + model: selection.model, + models: provider.models, + modelOptions: selection.options, + promptInjectionState: getComposerPromptInjectionState(messageTextForSend), + planModeEnabled: settings.planModeEnabled, + }); + const text = formatOutgoingPrompt({ + provider: provider.driverKind, + model: selection.model, + models: provider.models, + effort: providerState.promptEffort, + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + }); + if (composerRef.current?.validateProviderInput(text) === false) return; + multipleTargets.push({ + selection: createModelSelection( + selection.instanceId, + selection.model, + providerState.modelOptionsForDispatch, + ), + text, + interactionMode: resolveComposerInteractionMode({ + planModeEnabled: settings.planModeEnabled, + provider: provider.snapshot, + interactionMode: sendInteractionMode, + }).interactionMode, + }); + } + sendInFlightRef.current = true; + const sendGeneration = ++composerSendGenerationRef.current; // Every early return above leaves a queued message in the queue for a // later retry. From here on a failure hands it back to the composer. if (queuedMessage) { @@ -7736,7 +7841,9 @@ export default function ChatView(props: ChatViewProps) { } const resolvedSubmissionIntent = - submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; + (multipleModelSelections !== null || submissionIntent === "background") && isLocalDraftThread + ? "background" + : "foreground"; if ( shouldDockDraftHeroForSubmission({ isDraftHeroState, @@ -7771,7 +7878,7 @@ export default function ChatView(props: ChatViewProps) { return; } beginLocalDispatch({ - preparingWorktree: Boolean(baseBranchForWorktree), + preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), submissionIntent: resolvedSubmissionIntent, }); @@ -7800,6 +7907,267 @@ export default function ChatView(props: ChatViewProps) { }; }), ); + if (multipleModelSelections !== null) { + const failedSelections: ModelSelection[] = []; + let clearedDraft = false; + let releasedComposer = false; + let canRestoreDraft = () => false; + let startedCount = 0; + try { + const attachments = await turnAttachmentsPromise; + const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; + if (fileBlockReason !== null) throw new Error(fileBlockReason); + const context = buildOutgoingMessageContext( + attachments.map((attachment, index) => + "id" in attachment && attachment.id !== undefined + ? attachment.id + : composerAttachmentsSnapshot[index]!.id, + ), + ); + const title = truncate( + assistantCitationsToPlainText(stripInlineContextReferences(trimmed)).trim() || + composerAttachmentsSnapshot[0]?.name || + "New thread", + ); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + clearedDraft = true; + const clearedDraftSnapshot = useComposerDraftStore + .getState() + .getComposerDraft(composerDraftTarget); + const submittedSelections = multipleModelSelectionsRef.current; + canRestoreDraft = () => + currentRouteThreadKeyRef.current === routeThreadKey && + composerSendGenerationRef.current === sendGeneration && + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === + clearedDraftSnapshot && + multipleModelSelectionsRef.current === submittedSelections; + setThreadError(threadIdForSend, null); + const starts = Promise.all( + multipleTargets.map(async (target) => { + const retryKey = JSON.stringify([ + routeThreadKey, + target.selection.instanceId, + target.selection.model, + ]); + const uncertainThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey); + const targetThreadId = uncertainThreadId ?? newThreadId(); + let requestMayHaveStarted = false; + try { + if (uncertainThreadId) { + throw new Error( + "The previous request may have started. Open its thread to check before sending again.", + ); + } + const supportsInlineMessageContext = + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment + .capabilities.inlineMessageContext === true; + requestMayHaveStarted = true; + const result = await startThreadTurn({ + environmentId, + input: { + threadId: targetThreadId, + message: { + messageId: newMessageId(), + role: "user", + text: + context && !supportsInlineMessageContext + ? serializeLegacyContextMessage({ + text: target.text, + records: context.records, + }) + : target.text, + attachments, + ...(context && supportsInlineMessageContext ? { context } : {}), + }, + modelSelection: target.selection, + titleSeed: title, + runtimeMode, + interactionMode: target.interactionMode, + bootstrap: { + createThread: { + projectId: activeProject.id, + title, + modelSelection: target.selection, + runtimeMode, + interactionMode: target.interactionMode, + branch: activeThreadBranch, + worktreePath: null, + createdAt: messageCreatedAt, + }, + prepareWorktree: { + projectCwd: activeProject.workspaceRoot, + baseBranch: activeThreadBranch!, + requireWorktree: true, + branch: buildTemporaryWorktreeBranchName(randomHex), + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }, + runSetupScript: true, + }, + createdAt: messageCreatedAt, + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) { + requestMayHaveStarted = false; + } + throw error; + } + startedCount += 1; + } catch (error) { + if (requestMayHaveStarted && !uncertainMultipleSubmissionsRef.current.has(retryKey)) { + uncertainMultipleSubmissionsRef.current.set(retryKey, targetThreadId); + } + failedSelections.push(target.selection); + const retainedThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey); + const failureToastId = toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not start ${target.selection.model}`, + description: error instanceof Error ? error.message : "Failed to send message.", + ...(retainedThreadId + ? { + timeout: 0, + data: { + secondaryActionProps: { + children: "Allow retry", + onClick: () => { + void readLocalApi() + ?.dialogs.confirm( + "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?", + ) + .then( + (confirmed) => { + if ( + confirmed && + uncertainMultipleSubmissionsRef.current.get(retryKey) === + retainedThreadId + ) { + uncertainMultipleSubmissionsRef.current.delete(retryKey); + toastManager.close(failureToastId); + } + }, + () => undefined, + ); + }, + }, + }, + actionProps: { + children: "Open thread", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(environmentId, retainedThreadId), + ), + }); + }, + }, + } + : {}), + }), + ); + } + }), + ); + // Each request now owns its background thread. The original draft is + // ready for another prompt while checkout and setup scripts finish. + sendInFlightRef.current = false; + resetLocalDispatch(); + releasedComposer = true; + await starts; + if (startedCount > 0) { + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Started ${startedCount} ${startedCount === 1 ? "thread" : "threads"} in background`, + }), + ); + } + if (failedSelections.length === 0 && turnUsesAttachmentUploads) { + releaseDraftAttachments(composerAttachmentsSnapshot); + } + } catch (error) { + failedSelections.push(...multipleModelSelections); + setThreadError( + threadIdForSend, + error instanceof Error ? error.message : "Failed to send messages.", + ); + } finally { + const restoreFailedDraft = () => { + setMultipleModelSelections(failedSelections); + if (clearedDraft) { + setComposerDraftPrompt(composerDraftTarget, messageTextForSend); + addComposerDraftImages( + composerDraftTarget, + composerImagesSnapshot.map(cloneComposerImageForRetry), + ); + addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); + setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); + setComposerDraftPreviewAnnotations( + composerDraftTarget, + composerPreviewAnnotationsSnapshot, + ); + setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); + if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = messageTextForSend; + composerRef.current.resetCursorState({ + cursor: collapseExpandedComposerCursor( + messageTextForSend, + messageTextForSend.length, + ), + prompt: messageTextForSend, + detectTrigger: true, + }); + } + } + }; + if (failedSelections.length > 0) { + if (canRestoreDraft() && composerRef.current) { + restoreFailedDraft(); + } else if (clearedDraft) { + const recoveryToastId = toastManager.add( + stackedThreadToast({ + type: "error", + title: "A background prompt could not be sent", + description: + "Your newer draft is unchanged. Restore the failed prompt when this composer is empty.", + timeout: 0, + actionProps: { + children: "Restore prompt", + onClick: () => { + if ( + !draftId || + !useComposerDraftStore.getState().getDraftSession(draftId) || + sendInFlightRef.current || + composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) + ) { + toastManager.update(recoveryToastId, { + description: + "Return to the original draft and send or clear its current prompt before restoring.", + }); + return; + } + restoreFailedDraft(); + void navigate({ to: "/draft/$draftId", params: { draftId } }); + toastManager.close(recoveryToastId); + }, + }, + }), + ); + } + } + if (!releasedComposer) { + sendInFlightRef.current = false; + resetLocalDispatch(); + } + } + return; + } const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => attachment.type === "image" ? { @@ -8876,7 +9244,7 @@ export default function ChatView(props: ChatViewProps) { ); const onProviderModelSelect = useCallback( - (instanceId: ProviderInstanceId, model: string) => { + (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => { if (!activeThread) return; // Look up the configured instance so model normalization and custom // model lookup stay scoped to that exact instance. Unknown instance ids @@ -8888,7 +9256,7 @@ export default function ChatView(props: ChatViewProps) { resolvedDriverKind !== null && resolvedDriverKind !== lockedProvider ) { - scheduleComposerFocus(); + if (options?.focusComposer !== false) scheduleComposerFocus(); return; } if (lockedProvider !== null && activeThread.session?.providerInstanceId) { @@ -8900,7 +9268,7 @@ export default function ChatView(props: ChatViewProps) { entry?.continuation?.groupKey && currentEntry.continuation.groupKey !== entry.continuation.groupKey ) { - scheduleComposerFocus(); + if (options?.focusComposer !== false) scheduleComposerFocus(); return; } } @@ -8911,7 +9279,7 @@ export default function ChatView(props: ChatViewProps) { model, ); if (!resolvedModel) { - scheduleComposerFocus(); + if (options?.focusComposer !== false) scheduleComposerFocus(); return; } const nextModelSelection: ModelSelection = { @@ -8931,7 +9299,7 @@ export default function ChatView(props: ChatViewProps) { title: modelChangeBlockReason.title, description: modelChangeBlockReason.description, }); - scheduleComposerFocus(); + if (options?.focusComposer !== false) scheduleComposerFocus(); return; } setComposerDraftModelSelection( @@ -8940,7 +9308,7 @@ export default function ChatView(props: ChatViewProps) { { explicit: true }, ); setStickyComposerModelSelection(nextModelSelection); - scheduleComposerFocus(); + if (options?.focusComposer !== false) scheduleComposerFocus(); }, [ activeThread, @@ -8954,6 +9322,7 @@ export default function ChatView(props: ChatViewProps) { ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { + if (multipleModelSelections !== null) return; if (canOverrideServerThreadEnvMode) { setPendingServerThreadEnvMode(mode); scheduleComposerFocus(); @@ -8976,6 +9345,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, + multipleModelSelections, activeProjectSettings.settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, @@ -9639,6 +10009,12 @@ export default function ChatView(props: ChatViewProps) {
| null; providerAvailable: boolean; selectedProvider: ProviderDriverKind; selectedModel: string; @@ -1275,6 +1277,7 @@ export interface ChatComposerHandle { }; /** Validate the fully composed text immediately before a provider turn starts. */ validateProviderInput: (providerInput: string) => boolean; + setMultipleModelSelections: (selections: ReadonlyArray) => void; } // -------------------------------------------------------------------------- @@ -1291,6 +1294,11 @@ export interface ChatComposerProps { routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; + multipleModelSelections: ReadonlyArray | null; + supportsMultipleModels: boolean; + onMultipleModelSelectionsChange: React.Dispatch< + React.SetStateAction | null> + >; // Thread context activeThreadId: ThreadId | null; @@ -1419,7 +1427,11 @@ export interface ChatComposerProps { cursorAdjacentToMention: boolean, ) => void; - onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; + onProviderModelSelect: ( + instanceId: ProviderInstanceId, + model: string, + options?: { focusComposer?: boolean }, + ) => void; onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; toggleInteractionMode: () => void; @@ -1448,6 +1460,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) routeKind, routeThreadRef, draftId, + multipleModelSelections, + supportsMultipleModels, + onMultipleModelSelectionsChange: setMultipleModelSelections, activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, @@ -1846,7 +1861,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const selectedInstanceId = selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; - const noProviderAvailable = selectedProviderEntry === undefined; + const noProviderAvailable = + selectedProviderEntry === undefined && multipleModelSelections === null; // Before the catalog arrives, every thread resolves to "no provider". Send // stays blocked either way; only the chrome waits, keeping the picker with // the thread's own selection instead of swapping in the setup button and @@ -1882,9 +1898,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const sendDisabledReason = externalSendDisabledReason ?? + (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? (activePendingProgress ? attachmentBlockReason - : (attachmentBlockReason ?? providerSendBlockReason)); + : (attachmentBlockReason ?? + (multipleModelSelections === null ? providerSendBlockReason : null))); const isSendDisabled = sendDisabledReason !== null; const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, @@ -4912,7 +4930,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} { + const current = multipleModelSelections ?? [selectedModelSelection]; + const matchesModel = (selection: ModelSelection) => { + if (selection.instanceId !== instanceId) return false; + const entry = providerInstanceEntries.find( + (entry) => entry.instanceId === selection.instanceId, + ); + const resolvedModel = resolveModelPickerSelectedModel({ + driverKind: entry?.driverKind, + model: selection.model, + options: modelOptionsByInstance.get(selection.instanceId) ?? [], + }); + return (resolvedModel?.slug ?? selection.model) === model; + }; + const exists = current.some(matchesModel); + const next = exists + ? current.filter((selection) => !matchesModel(selection)) + : [...current, createModelSelection(instanceId, model)]; + if (next.length > 1) { + setMultipleModelSelections(next); + } else { + setMultipleModelSelections(null); + const remaining = next[0] ?? selectedModelSelection; + onProviderModelSelect(remaining.instanceId, remaining.model, { + focusComposer: false, + }); + } + }, + } + : {})} activeInstanceId={ providerCatalogPending ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId) @@ -4952,7 +5005,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : {})} onOpenChange={setIsComposerModelPickerOpen} getModelDisabledReason={getModelDisabledReason} - onInstanceModelChange={onProviderModelSelect} + onInstanceModelChange={(instanceId, model) => { + setMultipleModelSelections(null); + onProviderModelSelect(instanceId, model); + }} onOpenProviderSetup={onOpenProviderSetup} /> @@ -5871,13 +5927,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, - providerAvailable: !noProviderAvailable && providerSendBlockReason === null, + multipleModelSelections: + routeKind === "draft" && multipleModelSelections !== null + ? multipleModelSelections.map((selection) => + selection.instanceId === selectedModelSelection.instanceId && + selection.model === selectedModelSelection.model + ? selectedModelSelection + : selection, + ) + : null, + providerAvailable: + multipleModelSelections !== null || + (!noProviderAvailable && providerSendBlockReason === null), selectedProvider, selectedModel, selectedProviderModels, interactionMode, interactionModeEnabled: planModeUiEnabled, }), + setMultipleModelSelections, validateProviderInput: (providerInput: string) => { const validationMessage = getComposerSubmissionValidationMessage({ prompt: promptRef.current, @@ -5920,6 +5988,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, + multipleModelSelections, + setMultipleModelSelections, + routeKind, noProviderAvailable, providerSendBlockReason, selectedPromptEffort, diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index caa595dffd86..5917b4e2ca24 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,6 +1,6 @@ import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; import { memo } from "react"; -import { StarIcon } from "lucide-react"; +import { CheckIcon, StarIcon } from "lucide-react"; import { getDisplayModelName, getTriggerDisplayModelLabel, @@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: { providerAccentColor?: string | undefined; isFavorite: boolean; isSelected: boolean; + showSelection?: boolean; showProvider: boolean; preferShortName?: boolean; useTriggerLabel?: boolean; @@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: {
+ {props.showSelection && props.isSelected ? ( +