From 70d001c0c38d5e2eaf823da1e55d66b35777684b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:00:22 -0700 Subject: [PATCH] refactor(web): multiple-model sends live in their own hook Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 1 - apps/web/src/components/chat/chatViewSend.ts | 37 +++ .../src/components/chat/useComposerSend.ts | 244 ++------------- .../components/chat/useMultipleModelSend.ts | 293 ++++++++++++++++++ 4 files changed, 364 insertions(+), 211 deletions(-) create mode 100644 apps/web/src/components/chat/useMultipleModelSend.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0546e0098fb3e..88ea30cdc2cd2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3032,7 +3032,6 @@ export default function ChatView(props: ChatViewProps) { setSetupQueueSendThreadKey, setOptimisticUserMessages, setFeedbackSubmissionsByThreadKey, - setMultipleModelSelections, setThreadError, setKeepFullHistory, anchorNewTurn, diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts index 1061319983681..9755311a9a68b 100644 --- a/apps/web/src/components/chat/chatViewSend.ts +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -17,6 +17,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { readLocalApi } from "../../localApi"; +import { getUploadedAttachments } from "../../lib/attachmentUploadQueue"; import { findRevertCheckpoint } from "../../session-logic"; import { type ChatMessage } from "../../types"; import { randomUUID } from "~/lib/utils"; @@ -38,6 +39,7 @@ import { resolveComposerInteractionMode, cloneComposerImageForRetry, type ComposerSendSnapshot, + readFileAsDataUrl, prepareRevertedMessageAttachments, waitForRevertedMessage, } from "../ChatView.logic"; @@ -326,3 +328,38 @@ export function resolveMultipleModelTargets(input: { }; }); } + +/** + * The attachments a turn sends: the server's upload records when it takes uploads, otherwise + * images inlined as data URLs. Rejects when an attachment cannot go out. + */ +export function prepareTurnAttachments(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly usesAttachmentUploads: boolean; +}) { + const { environmentId, usesAttachmentUploads } = input; + return Promise.all( + input.attachments.map(async (attachment) => { + if (usesAttachmentUploads) { + const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; + if (!uploaded) { + throw new Error(`Attachment '${attachment.name}' did not finish uploading.`); + } + return uploaded; + } + if (attachment.type !== "image") { + throw new Error("This server does not support file attachments."); + } + return { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await readFileAsDataUrl(attachment.file), + ...(attachment.source ? { source: attachment.source } : {}), + }; + }), + ); +} diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index df465e048111e..3e9ec35ca1f25 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -5,7 +5,6 @@ import { restorePlanFollowUpComposer, buildExpiredTerminalContextToastCopy, deriveComposerSendState, - readFileAsDataUrl, revokeUserMessagePreviewUrls, boundAttachmentIds, formatOutgoingPrompt, @@ -84,6 +83,7 @@ import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { runMobileComposerTransition } from "./draftHeroTransition"; import { usePlanFollowUpSend } from "./useProposedPlanSend"; +import { useMultipleModelSend } from "./useMultipleModelSend"; import { type ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import { awaitAttachmentUploads, @@ -102,12 +102,11 @@ import { threadContextsOutsideEnvironment, } from "@t3tools/shared/composerContextReferences"; import { - draftFanoutSelectionsAtom, draftSendState, type MultipleModelTarget, resolveMultipleModelTargets, + prepareTurnAttachments, restoreComposerDraft, - startFanoutThread, } from "./chatViewSend"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import type { @@ -215,9 +214,6 @@ interface ComposerSendInput { readonly setFeedbackSubmissionsByThreadKey: Dispatch< SetStateAction>> >; - readonly setMultipleModelSelections: ( - selections: SetStateAction | null>, - ) => void; readonly setThreadError: ReturnType; readonly setKeepFullHistory: (threadKey: string, keep: boolean) => void; readonly anchorNewTurn: TimelineFollow["anchorNewTurn"]; @@ -310,7 +306,6 @@ export function useComposerSend(input: ComposerSendInput) { setSetupQueueSendThreadKey, setOptimisticUserMessages, setFeedbackSubmissionsByThreadKey, - setMultipleModelSelections, setThreadError, setKeepFullHistory, anchorNewTurn, @@ -337,6 +332,19 @@ export function useComposerSend(input: ComposerSendInput) { const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const sendToMultipleModels = useMultipleModelSend({ + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + draftId, + composerDraftTarget, + composerRef, + promptRef, + runtimeMode, + startFromOrigin, + resetLocalDispatch, + setThreadError, + }); const submitPlanFollowUp = usePlanFollowUpSend({ environmentId, routeThreadKey, @@ -356,10 +364,7 @@ export function useComposerSend(input: ComposerSendInput) { anchorNewTurn, persistThreadSettingsForNextTurn, }); - const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); - const readMultipleModelSelections = () => appAtomRegistry.get(fanoutSelectionsAtom); - const { sendInFlight: sendInFlightRef, uncertainSubmissions: uncertainMultipleSubmissionsRef } = - draftSendState(routeThreadKey); + const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const composerSendGenerationRef = useRef(0); const environmentUnavailableSendToastSlotRef = useRef(0); // A send awaits uploads before it declines, so it reads the approvals pending by then. @@ -1029,207 +1034,26 @@ export function useComposerSend(input: ComposerSendInput) { ); } - const turnAttachmentsPromise = Promise.all( - composerAttachmentsSnapshot.map(async (attachment) => { - if (turnUsesAttachmentUploads) { - const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; - if (!uploaded) { - throw new Error(`Attachment '${attachment.name}' did not finish uploading.`); - } - return uploaded; - } - if (attachment.type !== "image") { - throw new Error("This server does not support file attachments."); - } - return { - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - dataUrl: await readFileAsDataUrl(attachment.file), - ...(attachment.source ? { source: attachment.source } : {}), - }; - }), - ); + const turnAttachmentsPromise = prepareTurnAttachments({ + environmentId, + attachments: composerAttachmentsSnapshot, + usesAttachmentUploads: turnUsesAttachmentUploads, + }); if (multipleModelSelections !== null) { - const failedSelections: ModelSelection[] = []; - let clearedDraft = false; - let releasedComposer = false; - let canRestoreDraft = () => false; - let startedCount = 0; - // Anything thrown here (an attachment the server can no longer take) - // fails every selection; the draft restore below still runs. - const sendResult = await settlePromise(async () => { - const attachments = await turnAttachmentsPromise; - const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; - if (fileBlockReason !== null) throw new Error(fileBlockReason); - const context = buildOutgoingMessageContext( - boundAttachmentIds(attachments, composerAttachmentsSnapshot), - ); - const title = deriveThreadTitleSeed({ - text: messageTextForSend, - attachments: composerAttachmentsSnapshot, - }); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); - clearedDraft = true; - const clearedDraftSnapshot = useComposerDraftStore - .getState() - .getComposerDraft(composerDraftTarget); - const submittedSelections = readMultipleModelSelections(); - canRestoreDraft = () => - currentRouteThreadKeyRef.current === routeThreadKey && - composerSendGenerationRef.current === sendGeneration && - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === - clearedDraftSnapshot && - readMultipleModelSelections() === submittedSelections; - setThreadError(threadIdForSend, null); - const starts = Promise.all( - multipleTargets.map(async (target) => { - const started = await startFanoutThread({ - retryKey: JSON.stringify([ - routeThreadKey, - target.selection.instanceId, - target.selection.model, - ]), - model: target.selection.model, - uncertainSubmissions: uncertainMultipleSubmissionsRef.current, - start: (targetThreadId) => { - const supportsInlineMessageContext = - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment - .capabilities.inlineMessageContext === true; - return startThreadTurn({ - environmentId, - input: { - threadId: targetThreadId, - message: { - messageId: newMessageId(), - role: "user", - attachments, - ...withMessageContext({ - text: target.text, - context, - supportsInlineMessageContext, - }), - }, - 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, - ...(startFromOrigin ? { startFromOrigin: true } : {}), - }, - runSetupScript: true, - }, - createdAt: messageCreatedAt, - }, - }); - }, - openThread: (threadId) => - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), - }), - }); - if (started) startedCount += 1; - else failedSelections.push(target.selection); - }), - ); - 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); - } + await sendToMultipleModels({ + threadId: threadIdForSend, + project: activeProject, + branch: activeThreadBranch!, + selections: multipleModelSelections, + targets: multipleTargets, + sendSnapshot, + createdAt: messageCreatedAt, + attachmentsPromise: turnAttachmentsPromise, + usesAttachmentUploads: turnUsesAttachmentUploads, + readFileBlockReason: () => readLiveAttachmentCapabilities().fileBlockReason, + buildOutgoingMessageContext, + isCurrentSend: () => composerSendGenerationRef.current === sendGeneration, }); - if (sendResult._tag === "Failure") { - const error = squashAtomCommandFailure(sendResult); - failedSelections.push(...multipleModelSelections); - setThreadError( - threadIdForSend, - error instanceof Error ? error.message : "Failed to send messages.", - ); - } - const restoreFailedDraft = () => { - setMultipleModelSelections(failedSelections); - if (clearedDraft) { - restoreComposerDraft(composerDraftTarget, sendSnapshot); - 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 = optimisticMessageAttachments(composerAttachmentsSnapshot); diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts new file mode 100644 index 0000000000000..a2d80ee04a65a --- /dev/null +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -0,0 +1,293 @@ +import { + type EnvironmentId, + type ModelSelection, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useNavigate } from "@tanstack/react-router"; +import { newMessageId } from "~/lib/utils"; +import { collapseExpandedComposerCursor } from "../../composer-logic"; +import { + type ComposerThreadTarget, + composerDraftHasUserContent, + type DraftId, + useComposerDraftStore, +} from "../../composerDraftStore"; +import type { ComposerHandleRef } from "../../composerHandleContext"; +import { releaseDraftAttachments } from "../../lib/attachmentUploadQueue"; +import type { buildMessageContext } from "../../lib/composerContextRecords"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { threadEnvironment } from "../../state/threads"; +import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + boundAttachmentIds, + type ComposerSendSnapshot, + type makeThreadErrorSetter, + withMessageContext, +} from "../ChatView.logic"; +import { + draftFanoutSelectionsAtom, + draftSendState, + type MultipleModelTarget, + type prepareTurnAttachments, + restoreComposerDraft, + startFanoutThread, +} from "./chatViewSend"; +import type { useLocalDispatchState } from "./useLocalDispatchState"; + +interface MultipleModelSendInput { + readonly environmentId: EnvironmentId; + readonly routeThreadKey: string; + readonly currentRouteThreadKeyRef: { readonly current: string | null }; + readonly draftId: DraftId | null; + readonly composerDraftTarget: ComposerThreadTarget; + readonly composerRef: ComposerHandleRef; + readonly promptRef: { current: string }; + readonly runtimeMode: RuntimeMode; + readonly startFromOrigin: boolean; + readonly resetLocalDispatch: ReturnType["resetLocalDispatch"]; + readonly setThreadError: ReturnType; +} + +/** + * Starts one background thread per selected model, each in its own worktree. Failed models + * stay selected and the prompt comes back, either straight into this composer or, when the + * user has moved on, through a "Restore prompt" toast. + */ +export function useMultipleModelSend(input: MultipleModelSendInput) { + const { + environmentId, + routeThreadKey, + currentRouteThreadKeyRef, + draftId, + composerDraftTarget, + composerRef, + promptRef, + runtimeMode, + startFromOrigin, + resetLocalDispatch, + setThreadError, + } = input; + const navigate = useNavigate(); + const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { + reportFailure: false, + }); + const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); + const { sendInFlight: sendInFlightRef, uncertainSubmissions: uncertainSubmissionsRef } = + draftSendState(routeThreadKey); + + return async ({ + threadId, + project, + branch, + selections, + targets, + sendSnapshot, + createdAt, + attachmentsPromise, + usesAttachmentUploads, + readFileBlockReason, + buildOutgoingMessageContext, + isCurrentSend, + }: { + readonly threadId: ThreadId; + readonly project: EnvironmentProject; + readonly branch: string; + readonly selections: ReadonlyArray; + readonly targets: ReadonlyArray; + readonly sendSnapshot: ComposerSendSnapshot; + readonly createdAt: string; + readonly attachmentsPromise: ReturnType; + readonly usesAttachmentUploads: boolean; + readonly readFileBlockReason: () => string | null; + readonly buildOutgoingMessageContext: ( + attachmentIds: ReadonlyArray, + ) => ReturnType; + /** False once a newer send from this composer started. */ + readonly isCurrentSend: () => boolean; + }) => { + const { attachments: attachmentsSnapshot, messageText } = sendSnapshot; + const failedSelections: ModelSelection[] = []; + let clearedDraft = false; + let releasedComposer = false; + let canRestoreDraft = () => false; + let startedCount = 0; + // Anything thrown here (an attachment the server can no longer take) + // fails every selection; the draft restore below still runs. + const sendResult = await settlePromise(async () => { + const attachments = await attachmentsPromise; + const fileBlockReason = readFileBlockReason(); + if (fileBlockReason !== null) throw new Error(fileBlockReason); + const context = buildOutgoingMessageContext( + boundAttachmentIds(attachments, attachmentsSnapshot), + ); + const title = deriveThreadTitleSeed({ + text: messageText, + attachments: attachmentsSnapshot, + }); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + clearedDraft = true; + const clearedDraftSnapshot = useComposerDraftStore + .getState() + .getComposerDraft(composerDraftTarget); + const submittedSelections = appAtomRegistry.get(fanoutSelectionsAtom); + canRestoreDraft = () => + currentRouteThreadKeyRef.current === routeThreadKey && + isCurrentSend() && + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === + clearedDraftSnapshot && + appAtomRegistry.get(fanoutSelectionsAtom) === submittedSelections; + setThreadError(threadId, null); + const starts = Promise.all( + targets.map(async (target) => { + const started = await startFanoutThread({ + retryKey: JSON.stringify([ + routeThreadKey, + target.selection.instanceId, + target.selection.model, + ]), + model: target.selection.model, + uncertainSubmissions: uncertainSubmissionsRef.current, + start: (targetThreadId) => { + const supportsInlineMessageContext = + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment + .capabilities.inlineMessageContext === true; + return startThreadTurn({ + environmentId, + input: { + threadId: targetThreadId, + message: { + messageId: newMessageId(), + role: "user", + attachments, + ...withMessageContext({ + text: target.text, + context, + supportsInlineMessageContext, + }), + }, + modelSelection: target.selection, + titleSeed: title, + runtimeMode, + interactionMode: target.interactionMode, + bootstrap: { + createThread: { + projectId: project.id, + title, + modelSelection: target.selection, + runtimeMode, + interactionMode: target.interactionMode, + branch, + worktreePath: null, + createdAt: createdAt, + }, + prepareWorktree: { + projectCwd: project.workspaceRoot, + baseBranch: branch, + requireWorktree: true, + ...(startFromOrigin ? { startFromOrigin: true } : {}), + }, + runSetupScript: true, + }, + createdAt: createdAt, + }, + }); + }, + openThread: (threadId) => + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), + }), + }); + if (started) startedCount += 1; + else failedSelections.push(target.selection); + }), + ); + 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 && usesAttachmentUploads) { + releaseDraftAttachments(attachmentsSnapshot); + } + }); + if (sendResult._tag === "Failure") { + const error = squashAtomCommandFailure(sendResult); + failedSelections.push(...selections); + setThreadError(threadId, error instanceof Error ? error.message : "Failed to send messages."); + } + const restoreFailedDraft = () => { + appAtomRegistry.set(fanoutSelectionsAtom, failedSelections); + if (clearedDraft) { + restoreComposerDraft(composerDraftTarget, sendSnapshot); + if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = messageText; + composerRef.current.resetCursorState({ + cursor: collapseExpandedComposerCursor(messageText, messageText.length), + prompt: messageText, + 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(); + } + }; +}