# Files changed · PR \#23094 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23094/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[refactor\(web\): multiple-model sends live in their own hook](https://git.cafe/pingdotgg/t3code/pull/23094.md)

Version: 6

Head commit: 70d001c0c38d5e2eaf823da1e55d66b35777684b

Base commit: 9927f9e40537f202f9d8ca68f1f415a5b123bc86

Comparison base: 9927f9e40537f202f9d8ca68f1f415a5b123bc86

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/ChatView.tsx

Status: modified; +0; −1

```diff
@@ -3032,7 +3032,6 @@
     setSetupQueueSendThreadKey,
     setOptimisticUserMessages,
     setFeedbackSubmissionsByThreadKey,
-    setMultipleModelSelections,
     setThreadError,
     setKeepFullHistory,
     anchorNewTurn,
```

## apps/web/src/components/chat/chatViewSend.ts

Status: modified; +37; −0

```diff
@@ -17,6 +17,7 @@
   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 @@
   resolveComposerInteractionMode,
   cloneComposerImageForRetry,
   type ComposerSendSnapshot,
+  readFileAsDataUrl,
   prepareRevertedMessageAttachments,
   waitForRevertedMessage,
 } from "../ChatView.logic";
@@ -326,3 +328,38 @@
     };
   });
 }
+
+/**
+ * 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<ComposerImageAttachment | ComposerFileAttachment>;
+  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 } : {}),
+      };
+    }),
+  );
+}
```

## apps/web/src/components/chat/useComposerSend.ts

Status: modified; +34; −210

```diff
@@ -5,7 +5,6 @@
   restorePlanFollowUpComposer,
   buildExpiredTerminalContextToastCopy,
   deriveComposerSendState,
-  readFileAsDataUrl,
   revokeUserMessagePreviewUrls,
   boundAttachmentIds,
   formatOutgoingPrompt,
@@ -84,6 +83,7 @@
 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 @@
   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 @@
   readonly setFeedbackSubmissionsByThreadKey: Dispatch<
     SetStateAction<Record<string, ReadonlyArray<CodexFeedbackSubmission>>>
   >;
-  readonly setMultipleModelSelections: (
-    selections: SetStateAction<ReadonlyArray<ModelSelection> | null>,
-  ) => void;
   readonly setThreadError: ReturnType<typeof makeThreadErrorSetter>;
   readonly setKeepFullHistory: (threadKey: string, keep: boolean) => void;
   readonly anchorNewTurn: TimelineFollow["anchorNewTurn"];
@@ -310,7 +306,6 @@
     setSetupQueueSendThreadKey,
     setOptimisticUserMessages,
     setFeedbackSubmissionsByThreadKey,
-    setMultipleModelSelections,
     setThreadError,
     setKeepFullHistory,
     anchorNewTurn,
@@ -337,6 +332,19 @@
   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 @@
     anchorNewTurn,
     persistThreadSettingsForNextTurn,
   });
-  const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey);
+  const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey);
-  const readMultipleModelSelections = () => appAtomRegistry.get(fanoutSelectionsAtom);
-  const { sendInFlight: sendInFlightRef, uncertainSubmissions: uncertainMultipleSubmissionsRef } =
-    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 @@
       );
     }
 
+    const turnAttachmentsPromise = prepareTurnAttachments({
+      environmentId,
+      attachments: composerAttachmentsSnapshot,
+      usesAttachmentUploads: turnUsesAttachmentUploads,
-    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 } : {}),
-        };
-      }),
+    });
-    );
     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,
+      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,
-                        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);
-        }
       });
-      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);
```

## apps/web/src/components/chat/useMultipleModelSend.ts

Status: added; +293; −0

```diff
@@ -1,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<typeof useLocalDispatchState>["resetLocalDispatch"];
+  readonly setThreadError: ReturnType<typeof makeThreadErrorSetter>;
+}
+
+/**
+ * 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<ModelSelection>;
+    readonly targets: ReadonlyArray<MultipleModelTarget>;
+    readonly sendSnapshot: ComposerSendSnapshot;
+    readonly createdAt: string;
+    readonly attachmentsPromise: ReturnType<typeof prepareTurnAttachments>;
+    readonly usesAttachmentUploads: boolean;
+    readonly readFileBlockReason: () => string | null;
+    readonly buildOutgoingMessageContext: (
+      attachmentIds: ReadonlyArray<string>,
+    ) => ReturnType<typeof buildMessageContext>;
+    /** 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();
+    }
+  };
+}
```
