From 03b01684bf6892c04e8f5da6c1f7eb0c45e86eb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:00:55 +0000 Subject: [PATCH 01/13] test(client-runtime): reproduce issue 23661 unanswered send never fails Co-Authored-By: Claude Opus 5.5 --- .../src/operations/commands.test.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index bcfae19403483..76eed21c0661a 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -22,9 +22,12 @@ import { import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { AVAILABLE_CONNECTION_STATE, @@ -66,13 +69,18 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct readonly projectionRequests?: ThreadId[]; readonly advertiseServerResolvedCommandContext?: boolean; readonly advertiseThreadStop?: boolean; + readonly dispatchNeverAnswers?: boolean; }) { const client = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => Effect.sync(() => { input.commands.push(command); return { sequence: input.commands.length }; - }), + }).pipe( + Effect.andThen((result) => + input.dispatchNeverAnswers ? Effect.never : Effect.succeed(result), + ), + ), [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (requestInput: { readonly threadId: ThreadId; }) => @@ -299,6 +307,41 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(layerTestCrypto)), ); + it.effect( + "fails a message send the server never answers while the connection stays healthy", + () => + Effect.gen(function* () { + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + dispatchNeverAnswers: true, + }); + + const send = yield* startThreadTurn({ + commandId: CommandId.make("command-unanswered"), + threadId: v2ThreadId, + message: { + messageId: MessageId.make("message-unanswered"), + role: "user", + text: "Are you there?", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* TestClock.adjust("5 minutes"); + + expect(commands).toHaveLength(1); + const exit = send.pollUnsafe(); + expect(exit !== undefined && Exit.isFailure(exit)).toBe(true); + yield* Fiber.interrupt(send); + }).pipe(Effect.provide(layerTestCrypto)), + ); + it.effect("retains projection-shaped delivery for servers without command context support", () => Effect.gen(function* () { const activeRunId = RunId.make("legacy-active-run"); From 49bab05144bbee910f81f81774850627126ed5d1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:04:40 +0000 Subject: [PATCH 02/13] fix(client-runtime): a message send the server never answers fails after two minutes Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/operations/commands.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 8663b433d2c22..b480286a6c20d 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -32,6 +32,7 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { getInitialServerConfig, request } from "../rpc/client.ts"; @@ -267,6 +268,30 @@ const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(fun const dispatch = (command: OrchestrationV2Command) => request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command); +export class MessageDispatchUnansweredError extends Schema.TaggedError()( + "MessageDispatchUnansweredError", + { message: Schema.String }, +) {} + +// The server answers a message dispatch once it commits the command, so a healthy socket that +// stays silent this long means a stalled pipeline. The server may still commit later, so the +// error says so instead of retrying, which could duplicate the message. +const MESSAGE_DISPATCH_ANSWER_TIMEOUT = "2 minutes"; + +const dispatchMessage = (command: OrchestrationV2Command) => + dispatch(command).pipe( + Effect.timeoutOrElse({ + duration: MESSAGE_DISPATCH_ANSWER_TIMEOUT, + orElse: () => + Effect.fail( + new MessageDispatchUnansweredError({ + message: + "The server did not confirm this message. It may still arrive; check the thread before sending it again.", + }), + ), + }), + ); + const getProjection = (threadId: ThreadId) => request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); @@ -687,7 +712,7 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( const requestedMode = input.dispatchMode ?? "auto"; if (requestedMode === "start") { - return yield* dispatch({ + return yield* dispatchMessage({ type: "message.dispatch", commandId, createdBy: "user", @@ -751,7 +776,7 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( const shouldSendTitleSeed = input.titleSeed !== undefined && (serverResolvesCommandContext || projection?.messages.length === 0); - return yield* dispatch({ + return yield* dispatchMessage({ type: "message.dispatch", commandId, createdBy: "user", From cdb0c173e62ebbb335bd946b3f61aba18d497d33 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:13:02 +0000 Subject: [PATCH 03/13] fix(web): Enter while a message is still sending sends the next one after it The composer ignored Enter while the previous send was in flight, so a second message typed during a slow or stalled send silently went nowhere. Remember that submit instead: once the earlier send starts its turn, the kept draft is sent behind it, unless the user edited it or left the thread. If the earlier send fails or times out, the draft stays in the composer with a notice and nothing is retried. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 50 ++++++++ apps/web/src/components/ChatView.logic.ts | 24 ++++ .../src/components/chat/useComposerSend.ts | 115 +++++++++++++++++- 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 426818dba7a24..bcf89abfe72ad 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -54,6 +54,7 @@ import { hasServerAcknowledgedLocalDispatch, resolveDraftPromotionNavigationTarget, waitForRevertedMessage, + composerDraftContentUnchanged, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); @@ -1071,6 +1072,55 @@ describe("snapshotComposerSend", () => { }); }); +describe("composerDraftContentUnchanged", () => { + const image = { + id: "img-1", + type: "image" as const, + name: "shot.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "blob:one", + file: new File([], "shot.png"), + }; + const submitted = { + prompt: "and then the tests", + images: [image], + files: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + modelSelectionByProvider: {}, + activeProvider: null, + runtimeMode: null, + interactionMode: null, + }; + + it("ignores attachment bookkeeping and model changes the user did not make to the message", () => { + expect( + composerDraftContentUnchanged(submitted, { + ...submitted, + images: [{ ...image, previewUrl: "blob:two" }], + persistedAttachments: [{ ...image, dataUrl: "data:" }], + activeProvider: ProviderInstanceId.make("codex"), + }), + ).toBe(true); + }); + + it("treats an edited prompt, attachment set, or cleared draft as a different message", () => { + expect(composerDraftContentUnchanged(submitted, { ...submitted, prompt: "and then" })).toBe( + false, + ); + expect(composerDraftContentUnchanged(submitted, { ...submitted, images: [] })).toBe(false); + expect(composerDraftContentUnchanged(submitted, { ...submitted, terminalContexts: [] })).toBe( + false, + ); + expect(composerDraftContentUnchanged(submitted, null)).toBe(false); + }); +}); + describe("planComposerTurn", () => { const base = { messageText: "hello", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0ce51ee5e5534..716aa3bdc63e8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -69,6 +69,7 @@ import { import { type ComposerFileAttachment, type ComposerImageAttachment, + type ComposerThreadDraftState, type DraftId, type DraftThreadState, } from "../composerDraftStore"; @@ -1491,6 +1492,29 @@ export interface ComposerSendContents { readonly threadContexts: ReadonlyArray; } +/** + * Whether a draft still holds the message the user submitted. Only its content + * counts: the model picker and attachment upload bookkeeping can change without + * the user editing the message. + */ +export function composerDraftContentUnchanged( + submitted: ComposerThreadDraftState | null, + current: ComposerThreadDraftState | null, +): boolean { + if (submitted === null || current === null) return submitted === current; + const ids = (attachments: ReadonlyArray<{ readonly id: string }>) => + attachments.map((attachment) => attachment.id).join("\n"); + return ( + submitted.prompt === current.prompt && + ids(submitted.images) === ids(current.images) && + ids(submitted.files) === ids(current.files) && + submitted.terminalContexts === current.terminalContexts && + submitted.previewAnnotations === current.previewAnnotations && + submitted.reviewComments === current.reviewComments && + submitted.threadContexts === current.threadContexts + ); +} + /** * Copies the composer contents a send dispatches, so a failed send can put the * same draft back. Terminal contexts that lost their text are dropped from both diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index ed7def98c88c8..a9230156061c6 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -14,6 +14,7 @@ import { snapshotComposerSend, withMessageContext, makeThreadErrorSetter, + composerDraftContentUnchanged, } from "../ChatView.logic"; import { isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { @@ -43,7 +44,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { createModelSelection } from "@t3tools/shared/model"; -import { type SetStateAction, useLayoutEffect, useRef } from "react"; +import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { @@ -72,6 +73,7 @@ import { composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, + type ComposerThreadDraftState, type DraftThreadEnvMode, useComposerDraftStore, DraftId, @@ -383,7 +385,8 @@ export function useComposerSend(input: ComposerSendInput) { respondingRequestIdsRef.current = respondingRequestIds; }, [pendingApprovals, respondingRequestIds]); - const onSend = async ( + // Resolves to true once the turn started; a send that stops earlier resolves otherwise. + const sendComposerDraft = async ( e?: { preventDefault: () => void }, dispatchMode: ComposerDispatchMode = "auto", submissionIntent: ComposerSubmissionIntent = "foreground", @@ -1420,7 +1423,115 @@ export function useComposerSend(input: ComposerSendInput) { ); if (!sendQueuesBehindSetup) resetLocalDispatch(); } + return turnStartSucceeded; }; + // Enter pressed while the previous message is still sending. The draft stays in the + // composer; once that send starts its turn, this one sends it, unless the user edited it, + // left the thread, or the earlier send did not start a turn (an unanswered send may still + // have landed, so nothing here retries it). Sends read the ref, since one finishing runs + // in the closure it started in; the state only re-renders so the effect below can fire. + type QueuedSubmit = { + readonly routeThreadKey: string; + readonly draft: ComposerThreadDraftState | null; + readonly dispatchMode: ComposerDispatchMode; + readonly submissionIntent: ComposerSubmissionIntent; + readonly afterSend: number; + readonly ready: boolean; + }; + const queuedSubmitRef = useRef(null); + const [queuedSubmit, setQueuedSubmitState] = useState(null); + // Only a send started here reports back whether its turn started; the resume and + // multiple-model sends that share the in-flight flag do not. + const composerSendsRunningRef = useRef(0); + const setQueuedSubmit = (next: QueuedSubmit | null) => { + queuedSubmitRef.current = next; + setQueuedSubmitState(next); + }; + + const onSend: typeof sendComposerDraft = async ( + e, + dispatchMode = "auto", + submissionIntent = "foreground", + directAnnotation, + ) => { + const queued = + queuedSubmitRef.current?.routeThreadKey === routeThreadKey ? queuedSubmitRef.current : null; + if ( + !directAnnotation && + ((composerSendsRunningRef.current > 0 && sendInFlightRef.current) || queued !== null) + ) { + e?.preventDefault(); + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + setQueuedSubmit( + composerDraftHasUserContent(draft) + ? { + routeThreadKey, + draft, + // Written as a follow-up to the message still sending, so it waits behind that + // turn instead of steering it. + dispatchMode: dispatchMode === "auto" ? "queue" : dispatchMode, + submissionIntent, + afterSend: queued?.afterSend ?? composerSendGenerationRef.current, + ready: queued?.ready ?? false, + } + : null, + ); + return; + } + const generationBefore = composerSendGenerationRef.current; + composerSendsRunningRef.current += 1; + const started = await sendComposerDraft( + e, + dispatchMode, + submissionIntent, + directAnnotation, + ).finally(() => { + composerSendsRunningRef.current -= 1; + }); + const sendGeneration = composerSendGenerationRef.current; + const waiting = queuedSubmitRef.current; + if ( + sendGeneration === generationBefore || + waiting === null || + waiting.ready || + waiting.afterSend !== sendGeneration + ) { + return started; + } + if (started === true) { + setQueuedSubmit({ ...waiting, ready: true }); + return started; + } + setQueuedSubmit(null); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Your next message was not sent", + description: "It is still in the composer. Send it when you're ready.", + }), + ); + return started; + }; + const onSendRef = useRef(onSend); + useLayoutEffect(() => { + onSendRef.current = onSend; + }); + + const queuedSubmitBlocked = + (isSendBusy && !sendQueuesBehindSetup) || isConnecting || threadDetailLoading; + useEffect(() => { + const next = queuedSubmit; + if (next === null) return; + const leftThread = next.routeThreadKey !== routeThreadKey; + if (!leftThread && (!next.ready || queuedSubmitBlocked || sendInFlightRef.current)) return; + queuedSubmitRef.current = null; + setQueuedSubmitState(null); + if (leftThread) return; + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + if (!composerDraftContentUnchanged(next.draft, draft)) return; + void onSendRef.current(undefined, next.dispatchMode, next.submissionIntent); + }, [composerDraftTarget, queuedSubmit, queuedSubmitBlocked, routeThreadKey, sendInFlightRef]); + return { onSend }; } From c01ade8b390c45ceda43c229a4b56b58bbf9054e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:27:10 +0000 Subject: [PATCH 04/13] fix(client-runtime): a first-message thread launch the server never answers also fails after two minutes Co-Authored-By: Claude Opus 5.5 --- .../src/operations/commands.test.ts | 101 +++++++++++------- .../client-runtime/src/operations/commands.ts | 54 +++++----- 2 files changed, 95 insertions(+), 60 deletions(-) diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 76eed21c0661a..0d07ff34bc756 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -20,10 +20,10 @@ import { type ProjectMutation, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -41,9 +41,11 @@ import { v2Now, v2Projection, v2ThreadId } from "../state/orchestrationV2TestFix import { archiveThread, interruptThreadTurn, + MessageDispatchUnansweredError, revertThreadCheckpoint, resumeThreadQueue, startThreadTurn, + type StartThreadTurnInput, } from "./commands.ts"; const layerTestCrypto = Layer.succeed( @@ -69,7 +71,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct readonly projectionRequests?: ThreadId[]; readonly advertiseServerResolvedCommandContext?: boolean; readonly advertiseThreadStop?: boolean; - readonly dispatchNeverAnswers?: boolean; + readonly neverAnswers?: boolean; }) { const client = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => @@ -77,9 +79,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct input.commands.push(command); return { sequence: input.commands.length }; }).pipe( - Effect.andThen((result) => - input.dispatchNeverAnswers ? Effect.never : Effect.succeed(result), - ), + Effect.andThen((result) => (input.neverAnswers ? Effect.never : Effect.succeed(result))), ), [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (requestInput: { readonly threadId: ThreadId; @@ -96,7 +96,9 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct projection: input.projection ?? v2Projection, resumed: false, }; - }), + }).pipe( + Effect.andThen((result) => (input.neverAnswers ? Effect.never : Effect.succeed(result))), + ), [WS_METHODS.projectsMutate]: (mutation: ProjectMutation) => Effect.sync(() => { input.projects.push(mutation); @@ -307,39 +309,66 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(layerTestCrypto)), ); + const expectUnansweredAfterTwoMinutes = Effect.fn("TestEnvironmentCommands.expectUnanswered")( + function* (bootstrap: StartThreadTurnInput["bootstrap"]) { + const commands: OrchestrationV2Command[] = []; + const launches: OrchestrationV2ThreadLaunchInput[] = []; + const supervisor = yield* makeSupervisor({ + commands, + launches, + projects: [], + neverAnswers: true, + }); + + const send = yield* startThreadTurn({ + commandId: CommandId.make("command-unanswered"), + threadId: v2ThreadId, + message: { + messageId: MessageId.make("message-unanswered"), + role: "user", + text: "Are you there?", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + ...(bootstrap === undefined ? {} : { bootstrap }), + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* TestClock.adjust("119 seconds"); + expect(send.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("1 second"); + + expect(commands.length + launches.length).toBe(1); + const exit = send.pollUnsafe(); + expect(exit !== undefined && Exit.isFailure(exit) ? Cause.squash(exit.cause) : exit).toEqual( + new MessageDispatchUnansweredError({ + message: + "The server did not confirm this message. It may still arrive; check the thread before sending it again.", + }), + ); + }, + ); + it.effect( "fails a message send the server never answers while the connection stays healthy", - () => - Effect.gen(function* () { - const commands: OrchestrationV2Command[] = []; - const supervisor = yield* makeSupervisor({ - commands, - projects: [], - dispatchNeverAnswers: true, - }); - - const send = yield* startThreadTurn({ - commandId: CommandId.make("command-unanswered"), - threadId: v2ThreadId, - message: { - messageId: MessageId.make("message-unanswered"), - role: "user", - text: "Are you there?", - attachments: [], - }, - runtimeMode: "full-access", - interactionMode: "default", - }).pipe( - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.forkChild, - ); - yield* TestClock.adjust("5 minutes"); + () => expectUnansweredAfterTwoMinutes(undefined).pipe(Effect.provide(layerTestCrypto)), + ); - expect(commands).toHaveLength(1); - const exit = send.pollUnsafe(); - expect(exit !== undefined && Exit.isFailure(exit)).toBe(true); - yield* Fiber.interrupt(send); - }).pipe(Effect.provide(layerTestCrypto)), + it.effect("fails a first-message launch the server never answers", () => + expectUnansweredAfterTwoMinutes({ + createThread: { + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: v2Projection.thread.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-06-20T00:00:00.000Z", + }, + }).pipe(Effect.provide(layerTestCrypto)), ); it.effect("retains projection-shaped delivery for servers without command context support", () => diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index b480286a6c20d..52847c5073327 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -273,13 +273,14 @@ export class MessageDispatchUnansweredError extends Schema.TaggedError - dispatch(command).pipe( +const requireMessageAnswer = (effect: Effect.Effect) => + effect.pipe( Effect.timeoutOrElse({ duration: MESSAGE_DISPATCH_ANSWER_TIMEOUT, orElse: () => @@ -292,6 +293,9 @@ const dispatchMessage = (command: OrchestrationV2Command) => }), ); +const dispatchMessage = (command: OrchestrationV2Command) => + requireMessageAnswer(dispatch(command)); + const getProjection = (threadId: ThreadId) => request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId }); @@ -689,25 +693,27 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( ? {} : { branch: bootstrap.branch }), }; - return yield* request(ORCHESTRATION_V2_WS_METHODS.launchThread, { - commandId, - creationSource: input.creationSource ?? "web", - threadId: input.threadId, - ...(bootstrap === undefined ? { reuseExistingThread: true } : {}), - projectId: thread.projectId, - title: input.titleSeed ?? thread.title, - generateTitle: input.titleSeed !== undefined, - modelSelection: input.modelSelection ?? thread.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - workspaceStrategy, - initialMessage: { - messageId: input.message.messageId, - text: input.message.text, - ...(context ? { context } : {}), - attachments, - }, - }); + return yield* requireMessageAnswer( + request(ORCHESTRATION_V2_WS_METHODS.launchThread, { + commandId, + creationSource: input.creationSource ?? "web", + threadId: input.threadId, + ...(bootstrap === undefined ? { reuseExistingThread: true } : {}), + projectId: thread.projectId, + title: input.titleSeed ?? thread.title, + generateTitle: input.titleSeed !== undefined, + modelSelection: input.modelSelection ?? thread.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + workspaceStrategy, + initialMessage: { + messageId: input.message.messageId, + text: input.message.text, + ...(context ? { context } : {}), + attachments, + }, + }), + ); } const requestedMode = input.dispatchMode ?? "auto"; From cfb3ad9a0f654bbd400f9b4c09a370cf4ec18a2c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:27:12 +0000 Subject: [PATCH 05/13] fix(web): a queued Enter only follows its own send and never answers a new approval or question Settle the queued submit by the generation and thread of the send it waits behind, cancel it with the existing notice when an approval or question is pending, and keep text typed while a send is still preparing instead of clearing it. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 70 +++++++++++ apps/web/src/components/ChatView.logic.ts | 57 +++++++++ .../src/components/chat/useComposerSend.ts | 114 +++++++++++------- 3 files changed, 197 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bcf89abfe72ad..6a513c6b60cec 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -55,6 +55,8 @@ import { resolveDraftPromotionNavigationTarget, waitForRevertedMessage, composerDraftContentUnchanged, + resolveQueuedComposerSubmit, + settleQueuedComposerSubmit, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); @@ -1121,6 +1123,74 @@ describe("composerDraftContentUnchanged", () => { }); }); +describe("settleQueuedComposerSubmit", () => { + it("only lets the send it waits behind, on its own thread, release or cancel it", () => { + // A started on thread A (generation 1); the user moved to B, started B (generation 2), and + // pressed Enter again while B was sending. + const waiting = { routeThreadKey: "thread-b", afterSend: 2, ready: false }; + expect( + settleQueuedComposerSubmit({ + waiting, + sendRouteThreadKey: "thread-a", + sendGeneration: 1, + started: true, + }), + ).toBe("unrelated"); + expect( + settleQueuedComposerSubmit({ + waiting, + sendRouteThreadKey: "thread-b", + sendGeneration: 2, + started: false, + }), + ).toBe("cancel"); + expect( + settleQueuedComposerSubmit({ + waiting, + sendRouteThreadKey: "thread-b", + sendGeneration: 2, + started: true, + }), + ).toBe("ready"); + }); + + it("ignores a send that stopped before claiming a generation", () => { + expect( + settleQueuedComposerSubmit({ + waiting: { routeThreadKey: "thread-a", afterSend: 1, ready: false }, + sendRouteThreadKey: "thread-a", + sendGeneration: null, + started: false, + }), + ).toBe("unrelated"); + }); +}); + +describe("resolveQueuedComposerSubmit", () => { + const ready = { + ready: true, + leftThread: false, + sendBlocked: false, + pendingRequest: false, + draftUnchanged: true, + }; + + it("sends the queued message once the earlier send started and nothing blocks it", () => { + expect(resolveQueuedComposerSubmit(ready)).toBe("send"); + expect(resolveQueuedComposerSubmit({ ...ready, ready: false })).toBe("wait"); + expect(resolveQueuedComposerSubmit({ ...ready, sendBlocked: true })).toBe("wait"); + }); + + it("never turns the queued message into an answer to an approval or question that arrived", () => { + expect(resolveQueuedComposerSubmit({ ...ready, pendingRequest: true })).toBe("cancel"); + }); + + it("drops it silently once the user edited the draft or left the thread", () => { + expect(resolveQueuedComposerSubmit({ ...ready, draftUnchanged: false })).toBe("drop"); + expect(resolveQueuedComposerSubmit({ ...ready, ready: false, leftThread: true })).toBe("drop"); + }); +}); + describe("planComposerTurn", () => { const base = { messageText: "hello", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 716aa3bdc63e8..33413562a72a2 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1515,6 +1515,63 @@ export function composerDraftContentUnchanged( ); } +/** A draft the user pressed Enter on while their previous message was still sending. */ +export interface QueuedComposerSubmit { + readonly routeThreadKey: string; + readonly draft: ComposerThreadDraftState | null; + readonly dispatchMode: Mode; + readonly submissionIntent: Intent; + /** The send generation it waits behind. */ + readonly afterSend: number; + readonly ready: boolean; +} + +/** + * What a finished send does to the submit waiting behind it. Only the send it waits behind, + * on the same thread, settles it: another thread's send finishing says nothing about this one. + */ +export function settleQueuedComposerSubmit(input: { + readonly waiting: { + readonly routeThreadKey: string; + readonly afterSend: number; + readonly ready: boolean; + } | null; + readonly sendRouteThreadKey: string; + /** The generation the finished send claimed, or null if it stopped before claiming one. */ + readonly sendGeneration: number | null; + readonly started: boolean; +}): "unrelated" | "ready" | "cancel" { + const { waiting, sendGeneration } = input; + if ( + sendGeneration === null || + waiting === null || + waiting.ready || + waiting.routeThreadKey !== input.sendRouteThreadKey || + waiting.afterSend !== sendGeneration + ) { + return "unrelated"; + } + return input.started ? "ready" : "cancel"; +} + +/** + * Whether a ready queued submit sends now. It is the user's earlier Enter, so it only sends + * as the plain message they wrote: an approval or question that arrived meanwhile would turn + * it into an answer they never saw, so it is cancelled instead. + */ +export function resolveQueuedComposerSubmit(input: { + readonly ready: boolean; + readonly leftThread: boolean; + readonly sendBlocked: boolean; + readonly pendingRequest: boolean; + readonly draftUnchanged: boolean; +}): "wait" | "send" | "drop" | "cancel" { + if (input.leftThread) return "drop"; + if (!input.ready || input.sendBlocked) return "wait"; + if (!input.draftUnchanged) return "drop"; + return input.pendingRequest ? "cancel" : "send"; +} + /** * Copies the composer contents a send dispatches, so a failed send can put the * same draft back. Terminal contexts that lost their text are dropped from both diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index a9230156061c6..085dda9df6467 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -15,6 +15,9 @@ import { withMessageContext, makeThreadErrorSetter, composerDraftContentUnchanged, + resolveQueuedComposerSubmit, + settleQueuedComposerSubmit, + type QueuedComposerSubmit, } from "../ChatView.logic"; import { isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { @@ -73,7 +76,6 @@ import { composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, - type ComposerThreadDraftState, type DraftThreadEnvMode, useComposerDraftStore, DraftId, @@ -250,6 +252,15 @@ interface ComposerSendInput { * thread, a feedback upload, and resuming an interrupted run. Each path restores the draft * when it fails. */ +const notifyQueuedSubmitNotSent = () => + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Your next message was not sent", + description: "It is still in the composer. Send it when you're ready.", + }), + ); + export function useComposerSend(input: ComposerSendInput) { const { environmentId, @@ -864,8 +875,10 @@ export function useComposerSend(input: ComposerSendInput) { multipleTargets.push(resolved); } + // onSend reads the generation this claims right after calling, so no await may precede it. sendInFlightRef.current = true; const sendGeneration = ++composerSendGenerationRef.current; + const draftAtSend = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { sendInFlightRef.current = false; @@ -1153,9 +1166,20 @@ export function useComposerSend(input: ComposerSendInput) { : null; // Keep new-thread flows from reusing this draft while attachments and worktree setup settle. if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + // Uploads and the dock transition above are awaited; anything typed meanwhile is the next + // message, not this one. A direct annotation writes into the draft as it sends, so it + // always clears. + if ( + directAnnotation || + composerDraftContentUnchanged( + draftAtSend, + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) + ) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } const title = deriveThreadTitleSeed({ text: messageTextForSend, @@ -1428,17 +1452,11 @@ export function useComposerSend(input: ComposerSendInput) { // Enter pressed while the previous message is still sending. The draft stays in the // composer; once that send starts its turn, this one sends it, unless the user edited it, - // left the thread, or the earlier send did not start a turn (an unanswered send may still - // have landed, so nothing here retries it). Sends read the ref, since one finishing runs + // left the thread, an approval or question arrived, or the earlier send did not start a + // turn (an unanswered send may still have landed, so nothing here retries it). This covers + // the single-model composer send only; resume and multiple-model sends never queue one. Sends read the ref, since one finishing runs // in the closure it started in; the state only re-renders so the effect below can fire. - type QueuedSubmit = { - readonly routeThreadKey: string; - readonly draft: ComposerThreadDraftState | null; - readonly dispatchMode: ComposerDispatchMode; - readonly submissionIntent: ComposerSubmissionIntent; - readonly afterSend: number; - readonly ready: boolean; - }; + type QueuedSubmit = QueuedComposerSubmit; const queuedSubmitRef = useRef(null); const [queuedSubmit, setQueuedSubmitState] = useState(null); // Only a send started here reports back whether its turn started; the resume and @@ -1481,36 +1499,28 @@ export function useComposerSend(input: ComposerSendInput) { } const generationBefore = composerSendGenerationRef.current; composerSendsRunningRef.current += 1; - const started = await sendComposerDraft( - e, - dispatchMode, - submissionIntent, - directAnnotation, - ).finally(() => { + const sending = sendComposerDraft(e, dispatchMode, submissionIntent, directAnnotation); + // The send claims its generation before its first await. Read it now: once this awaits, + // a send started on another thread from this same composer moves the counter on. + const claimedGeneration = composerSendGenerationRef.current; + const sendGeneration = claimedGeneration === generationBefore ? null : claimedGeneration; + const started = await sending.finally(() => { composerSendsRunningRef.current -= 1; }); - const sendGeneration = composerSendGenerationRef.current; const waiting = queuedSubmitRef.current; - if ( - sendGeneration === generationBefore || - waiting === null || - waiting.ready || - waiting.afterSend !== sendGeneration - ) { - return started; - } - if (started === true) { + const settled = settleQueuedComposerSubmit({ + waiting, + sendRouteThreadKey: routeThreadKey, + sendGeneration, + started: started === true, + }); + if (settled === "unrelated" || waiting === null) return started; + if (settled === "ready") { setQueuedSubmit({ ...waiting, ready: true }); return started; } setQueuedSubmit(null); - toastManager.add( - stackedThreadToast({ - type: "info", - title: "Your next message was not sent", - description: "It is still in the composer. Send it when you're ready.", - }), - ); + notifyQueuedSubmitNotSent(); return started; }; const onSendRef = useRef(onSend); @@ -1520,18 +1530,34 @@ export function useComposerSend(input: ComposerSendInput) { const queuedSubmitBlocked = (isSendBusy && !sendQueuesBehindSetup) || isConnecting || threadDetailLoading; + const pendingRequest = pendingApprovals.length > 0 || Boolean(activePendingProgress); useEffect(() => { const next = queuedSubmit; if (next === null) return; - const leftThread = next.routeThreadKey !== routeThreadKey; - if (!leftThread && (!next.ready || queuedSubmitBlocked || sendInFlightRef.current)) return; + const action = resolveQueuedComposerSubmit({ + ready: next.ready, + leftThread: next.routeThreadKey !== routeThreadKey, + sendBlocked: queuedSubmitBlocked || sendInFlightRef.current, + pendingRequest: pendingRequest || pendingApprovalsRef.current.length > 0, + draftUnchanged: composerDraftContentUnchanged( + next.draft, + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ), + }); + if (action === "wait") return; queuedSubmitRef.current = null; setQueuedSubmitState(null); - if (leftThread) return; - const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); - if (!composerDraftContentUnchanged(next.draft, draft)) return; - void onSendRef.current(undefined, next.dispatchMode, next.submissionIntent); - }, [composerDraftTarget, queuedSubmit, queuedSubmitBlocked, routeThreadKey, sendInFlightRef]); + if (action === "cancel") notifyQueuedSubmitNotSent(); + if (action === "send") + void onSendRef.current(undefined, next.dispatchMode, next.submissionIntent); + }, [ + composerDraftTarget, + pendingRequest, + queuedSubmit, + queuedSubmitBlocked, + routeThreadKey, + sendInFlightRef, + ]); return { onSend }; } From 887f485adfe71413ecc10fd59e74255b878b7c08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 11:36:23 +0000 Subject: [PATCH 06/13] fix(web): a queued Enter waits behind its own thread's send after navigating The composer hook outlives thread navigation, so a submit queued on one thread took the send generation last started on any thread and could never be released, swallowing every later Enter. Each thread's running send is now tracked by route; a submit waits behind that send only, and a stale queued submit no running send can settle gives way to a new Enter. A send that rejects still settles the submit behind it with the not-sent notice before the error propagates. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 68 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 19 ++++++ .../src/components/chat/useComposerSend.ts | 57 ++++++++++------ 3 files changed, 123 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 6a513c6b60cec..b400cd4e80a4a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -55,6 +55,7 @@ import { resolveDraftPromotionNavigationTarget, waitForRevertedMessage, composerDraftContentUnchanged, + queueComposerSubmitBehind, resolveQueuedComposerSubmit, settleQueuedComposerSubmit, } from "./ChatView.logic"; @@ -1166,6 +1167,73 @@ describe("settleQueuedComposerSubmit", () => { }); }); +describe("queueComposerSubmitBehind", () => { + it("waits behind the send running on its own thread, not the one started last", () => { + // A is sending on thread X (generation 1); the user moved to Y, sent C (generation 2), came + // back to X and pressed Enter on B while both still send. + const runningSends = new Map([ + ["thread-x", 1], + ["thread-y", 2], + ]); + const behind = queueComposerSubmitBehind({ + routeThreadKey: "thread-x", + sendInFlight: true, + runningSends, + queued: null, + }); + expect(behind).toEqual({ afterSend: 1, ready: false }); + const waiting = { routeThreadKey: "thread-x", ...behind! }; + expect( + settleQueuedComposerSubmit({ + waiting, + sendRouteThreadKey: "thread-y", + sendGeneration: runningSends.get("thread-y")!, + started: true, + }), + ).toBe("unrelated"); + expect( + settleQueuedComposerSubmit({ + waiting, + sendRouteThreadKey: "thread-x", + sendGeneration: runningSends.get("thread-x")!, + started: true, + }), + ).toBe("ready"); + }); + + it("lets a new Enter send once no send on this thread can settle the queued one", () => { + const stale = { afterSend: 2, ready: false }; + expect( + queueComposerSubmitBehind({ + routeThreadKey: "thread-x", + sendInFlight: false, + runningSends: new Map(), + queued: stale, + }), + ).toBeNull(); + expect( + queueComposerSubmitBehind({ + routeThreadKey: "thread-x", + sendInFlight: true, + runningSends: new Map([["thread-y", 2]]), + queued: stale, + }), + ).toBeNull(); + }); + + it("keeps a submit that is already cleared to send", () => { + const ready = { afterSend: 1, ready: true }; + expect( + queueComposerSubmitBehind({ + routeThreadKey: "thread-x", + sendInFlight: false, + runningSends: new Map(), + queued: ready, + }), + ).toBe(ready); + }); +}); + describe("resolveQueuedComposerSubmit", () => { const ready = { ready: true, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 33413562a72a2..81511237967ae 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1526,6 +1526,25 @@ export interface QueuedComposerSubmit { readonly ready: boolean; } +/** + * Where Enter goes while a message may still be sending: null sends now, otherwise the + * submit waits behind the send running on this thread. `runningSends` holds each thread's + * running send generation, since one composer sends on several threads as the user moves. + * A queued submit no send on this thread will settle is stale and gives way to the new Enter. + */ +export function queueComposerSubmitBehind(input: { + readonly routeThreadKey: string; + /** Whether this thread's send is still in flight, rather than finishing up. */ + readonly sendInFlight: boolean; + readonly runningSends: ReadonlyMap; + readonly queued: { readonly afterSend: number; readonly ready: boolean } | null; +}): { readonly afterSend: number; readonly ready: boolean } | null { + const { queued } = input; + if (queued?.ready) return queued; + const running = input.sendInFlight ? input.runningSends.get(input.routeThreadKey) : undefined; + return running === undefined ? null : { afterSend: running, ready: false }; +} + /** * What a finished send does to the submit waiting behind it. Only the send it waits behind, * on the same thread, settles it: another thread's send finishing says nothing about this one. diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 085dda9df6467..662e1ee21a808 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -16,6 +16,7 @@ import { makeThreadErrorSetter, composerDraftContentUnchanged, resolveQueuedComposerSubmit, + queueComposerSubmitBehind, settleQueuedComposerSubmit, type QueuedComposerSubmit, } from "../ChatView.logic"; @@ -1453,15 +1454,16 @@ export function useComposerSend(input: ComposerSendInput) { // Enter pressed while the previous message is still sending. The draft stays in the // composer; once that send starts its turn, this one sends it, unless the user edited it, // left the thread, an approval or question arrived, or the earlier send did not start a - // turn (an unanswered send may still have landed, so nothing here retries it). This covers - // the single-model composer send only; resume and multiple-model sends never queue one. Sends read the ref, since one finishing runs - // in the closure it started in; the state only re-renders so the effect below can fire. + // turn (an unanswered send may still have landed, so nothing here retries it). A + // multiple-model send never reports a started turn, so a submit behind it is cancelled. + // Sends read the ref, since one finishing runs in the closure it started in; the state + // only re-renders so the effect below can fire. type QueuedSubmit = QueuedComposerSubmit; const queuedSubmitRef = useRef(null); const [queuedSubmit, setQueuedSubmitState] = useState(null); - // Only a send started here reports back whether its turn started; the resume and - // multiple-model sends that share the in-flight flag do not. - const composerSendsRunningRef = useRef(0); + // The generation of the send running on each thread. The hook outlives navigation, so a + // submit has to wait behind its own thread's send, not whichever send started last. + const runningSendsRef = useRef(new Map()); const setQueuedSubmit = (next: QueuedSubmit | null) => { queuedSubmitRef.current = next; setQueuedSubmitState(next); @@ -1475,10 +1477,15 @@ export function useComposerSend(input: ComposerSendInput) { ) => { const queued = queuedSubmitRef.current?.routeThreadKey === routeThreadKey ? queuedSubmitRef.current : null; - if ( - !directAnnotation && - ((composerSendsRunningRef.current > 0 && sendInFlightRef.current) || queued !== null) - ) { + const behind = directAnnotation + ? null + : queueComposerSubmitBehind({ + routeThreadKey, + sendInFlight: sendInFlightRef.current, + runningSends: runningSendsRef.current, + queued, + }); + if (behind !== null) { e?.preventDefault(); const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); setQueuedSubmit( @@ -1490,23 +1497,31 @@ export function useComposerSend(input: ComposerSendInput) { // turn instead of steering it. dispatchMode: dispatchMode === "auto" ? "queue" : dispatchMode, submissionIntent, - afterSend: queued?.afterSend ?? composerSendGenerationRef.current, - ready: queued?.ready ?? false, + ...behind, } : null, ); return; } + if (queued !== null) setQueuedSubmit(null); const generationBefore = composerSendGenerationRef.current; - composerSendsRunningRef.current += 1; const sending = sendComposerDraft(e, dispatchMode, submissionIntent, directAnnotation); // The send claims its generation before its first await. Read it now: once this awaits, // a send started on another thread from this same composer moves the counter on. const claimedGeneration = composerSendGenerationRef.current; const sendGeneration = claimedGeneration === generationBefore ? null : claimedGeneration; - const started = await sending.finally(() => { - composerSendsRunningRef.current -= 1; - }); + const runningSends = runningSendsRef.current; + if (sendGeneration !== null) runningSends.set(routeThreadKey, sendGeneration); + let started: boolean | undefined = false; + let failure: { readonly error: unknown } | null = null; + try { + started = await sending; + } catch (error) { + failure = { error }; + } + if (sendGeneration !== null && runningSends.get(routeThreadKey) === sendGeneration) { + runningSends.delete(routeThreadKey); + } const waiting = queuedSubmitRef.current; const settled = settleQueuedComposerSubmit({ waiting, @@ -1514,13 +1529,13 @@ export function useComposerSend(input: ComposerSendInput) { sendGeneration, started: started === true, }); - if (settled === "unrelated" || waiting === null) return started; - if (settled === "ready") { + if (waiting !== null && settled === "ready") { setQueuedSubmit({ ...waiting, ready: true }); - return started; + } else if (waiting !== null && settled === "cancel") { + setQueuedSubmit(null); + notifyQueuedSubmitNotSent(); } - setQueuedSubmit(null); - notifyQueuedSubmitNotSent(); + if (failure !== null) throw failure.error; return started; }; const onSendRef = useRef(onSend); From 5ef4585665385c5a8bf78c39e8ff29ea7d34d1f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:20:13 +0000 Subject: [PATCH 07/13] fix(web): a queued Enter follows main's draft shape and says when an edit cancels it - Compare environment contexts when deciding a queued draft is unchanged. - Hold the queued send while a checkpoint revert runs, matching the send guard; isConnecting no longer exists on main. - An edited queued draft is cancelled with the "not sent" toast instead of being dropped silently. - State reasons for the two effects, trim the long comment, keep the hook's JSDoc attached, and cut overlapping logic tests. AI-assisted; verified with the tests and tools listed above. --- .../web/src/components/ChatView.logic.test.ts | 117 ++++-------------- apps/web/src/components/ChatView.logic.ts | 10 +- .../src/components/chat/useComposerSend.ts | 26 ++-- 3 files changed, 42 insertions(+), 111 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 58c1d8bca64a0..e50eca8e237fd 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1087,6 +1087,7 @@ describe("composerDraftContentUnchanged", () => { previewAnnotations: [], reviewComments: [], threadContexts: [], + environmentContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -1121,76 +1122,28 @@ describe("settleQueuedComposerSubmit", () => { // A started on thread A (generation 1); the user moved to B, started B (generation 2), and // pressed Enter again while B was sending. const waiting = { routeThreadKey: "thread-b", afterSend: 2, ready: false }; - expect( - settleQueuedComposerSubmit({ - waiting, - sendRouteThreadKey: "thread-a", - sendGeneration: 1, - started: true, - }), - ).toBe("unrelated"); - expect( - settleQueuedComposerSubmit({ - waiting, - sendRouteThreadKey: "thread-b", - sendGeneration: 2, - started: false, - }), - ).toBe("cancel"); - expect( - settleQueuedComposerSubmit({ - waiting, - sendRouteThreadKey: "thread-b", - sendGeneration: 2, - started: true, - }), - ).toBe("ready"); - }); - - it("ignores a send that stopped before claiming a generation", () => { - expect( - settleQueuedComposerSubmit({ - waiting: { routeThreadKey: "thread-a", afterSend: 1, ready: false }, - sendRouteThreadKey: "thread-a", - sendGeneration: null, - started: false, - }), - ).toBe("unrelated"); + const settle = (sendRouteThreadKey: string, sendGeneration: number | null, started: boolean) => + settleQueuedComposerSubmit({ waiting, sendRouteThreadKey, sendGeneration, started }); + expect(settle("thread-a", 1, true)).toBe("unrelated"); + expect(settle("thread-b", null, false)).toBe("unrelated"); + expect(settle("thread-b", 2, false)).toBe("cancel"); + expect(settle("thread-b", 2, true)).toBe("ready"); }); }); describe("queueComposerSubmitBehind", () => { it("waits behind the send running on its own thread, not the one started last", () => { - // A is sending on thread X (generation 1); the user moved to Y, sent C (generation 2), came - // back to X and pressed Enter on B while both still send. - const runningSends = new Map([ - ["thread-x", 1], - ["thread-y", 2], - ]); - const behind = queueComposerSubmitBehind({ - routeThreadKey: "thread-x", - sendInFlight: true, - runningSends, - queued: null, - }); - expect(behind).toEqual({ afterSend: 1, ready: false }); - const waiting = { routeThreadKey: "thread-x", ...behind! }; - expect( - settleQueuedComposerSubmit({ - waiting, - sendRouteThreadKey: "thread-y", - sendGeneration: runningSends.get("thread-y")!, - started: true, - }), - ).toBe("unrelated"); expect( - settleQueuedComposerSubmit({ - waiting, - sendRouteThreadKey: "thread-x", - sendGeneration: runningSends.get("thread-x")!, - started: true, + queueComposerSubmitBehind({ + routeThreadKey: "thread-x", + sendInFlight: true, + runningSends: new Map([ + ["thread-x", 1], + ["thread-y", 2], + ]), + queued: null, }), - ).toBe("ready"); + ).toEqual({ afterSend: 1, ready: false }); }); it("lets a new Enter send once no send on this thread can settle the queued one", () => { @@ -1212,41 +1165,21 @@ describe("queueComposerSubmitBehind", () => { }), ).toBeNull(); }); - - it("keeps a submit that is already cleared to send", () => { - const ready = { afterSend: 1, ready: true }; - expect( - queueComposerSubmitBehind({ - routeThreadKey: "thread-x", - sendInFlight: false, - runningSends: new Map(), - queued: ready, - }), - ).toBe(ready); - }); }); describe("resolveQueuedComposerSubmit", () => { - const ready = { - ready: true, - leftThread: false, - sendBlocked: false, - pendingRequest: false, - draftUnchanged: true, - }; - - it("sends the queued message once the earlier send started and nothing blocks it", () => { + it("sends only the unedited message on its own thread, and never as an answer", () => { + const ready = { + ready: true, + leftThread: false, + sendBlocked: false, + pendingRequest: false, + draftUnchanged: true, + }; expect(resolveQueuedComposerSubmit(ready)).toBe("send"); - expect(resolveQueuedComposerSubmit({ ...ready, ready: false })).toBe("wait"); expect(resolveQueuedComposerSubmit({ ...ready, sendBlocked: true })).toBe("wait"); - }); - - it("never turns the queued message into an answer to an approval or question that arrived", () => { expect(resolveQueuedComposerSubmit({ ...ready, pendingRequest: true })).toBe("cancel"); - }); - - it("drops it silently once the user edited the draft or left the thread", () => { - expect(resolveQueuedComposerSubmit({ ...ready, draftUnchanged: false })).toBe("drop"); + expect(resolveQueuedComposerSubmit({ ...ready, draftUnchanged: false })).toBe("cancel"); expect(resolveQueuedComposerSubmit({ ...ready, ready: false, leftThread: true })).toBe("drop"); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 85ff2af2e73cb..13c7204e6caa7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1281,7 +1281,8 @@ export function composerDraftContentUnchanged( submitted.terminalContexts === current.terminalContexts && submitted.previewAnnotations === current.previewAnnotations && submitted.reviewComments === current.reviewComments && - submitted.threadContexts === current.threadContexts + submitted.threadContexts === current.threadContexts && + submitted.environmentContexts === current.environmentContexts ); } @@ -1345,8 +1346,8 @@ export function settleQueuedComposerSubmit(input: { /** * Whether a ready queued submit sends now. It is the user's earlier Enter, so it only sends - * as the plain message they wrote: an approval or question that arrived meanwhile would turn - * it into an answer they never saw, so it is cancelled instead. + * as the plain message they wrote: an edit, or an approval or question that arrived meanwhile + * (which would turn it into an answer they never saw), cancels it instead. */ export function resolveQueuedComposerSubmit(input: { readonly ready: boolean; @@ -1357,8 +1358,7 @@ export function resolveQueuedComposerSubmit(input: { }): "wait" | "send" | "drop" | "cancel" { if (input.leftThread) return "drop"; if (!input.ready || input.sendBlocked) return "wait"; - if (!input.draftUnchanged) return "drop"; - return input.pendingRequest ? "cancel" : "send"; + return input.draftUnchanged && !input.pendingRequest ? "send" : "cancel"; } /** diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index ffa41f574eeba..d932b201b1af6 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -252,12 +252,6 @@ interface ComposerSendInput { }) => Promise>; } -/** - * Everything the composer's send button can start: a turn on this thread or a new one, - * a multiple-model start, a queued or steering message, a plan follow-up or implementation - * thread, a feedback upload, and resuming an interrupted run. Each path restores the draft - * when it fails. - */ const notifyQueuedSubmitNotSent = () => toastManager.add( stackedThreadToast({ @@ -267,6 +261,12 @@ const notifyQueuedSubmitNotSent = () => }), ); +/** + * Everything the composer's send button can start: a turn on this thread or a new one, + * a multiple-model start, a queued or steering message, a plan follow-up or implementation + * thread, a feedback upload, and resuming an interrupted run. Each path restores the draft + * when it fails. + */ export function useComposerSend(input: ComposerSendInput) { const { environmentId, @@ -1429,13 +1429,9 @@ export function useComposerSend(input: ComposerSendInput) { return turnStartSucceeded; }; - // Enter pressed while the previous message is still sending. The draft stays in the - // composer; once that send starts its turn, this one sends it, unless the user edited it, - // left the thread, an approval or question arrived, or the earlier send did not start a - // turn (an unanswered send may still have landed, so nothing here retries it). A - // multiple-model send never reports a started turn, so a submit behind it is cancelled. - // Sends read the ref, since one finishing runs in the closure it started in; the state - // only re-renders so the effect below can fire. + // Enter while the previous message is still sending keeps the draft and sends it once that + // send starts its turn. A send that did not start one may still land, so it is never retried. + // Finishing sends read the ref from their own closure; the state only re-renders the effect. type QueuedSubmit = QueuedComposerSubmit; const queuedSubmitRef = useRef(null); const [queuedSubmit, setQueuedSubmitState] = useState(null); @@ -1517,13 +1513,15 @@ export function useComposerSend(input: ComposerSendInput) { return started; }; const onSendRef = useRef(onSend); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the queued-submit effect calls the latest onSend without re-running on every render useLayoutEffect(() => { onSendRef.current = onSend; }); const queuedSubmitBlocked = - (isSendBusy && !sendQueuesBehindSetup) || isConnecting || threadDetailLoading; + (isSendBusy && !sendQueuesBehindSetup) || isRevertingCheckpoint || threadDetailLoading; const pendingRequest = pendingApprovals.length > 0 || Boolean(activePendingProgress); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fires the deferred Enter once server state (send settled, connection, pending requests) lets it; no user action marks that moment useEffect(() => { const next = queuedSubmit; if (next === null) return; From 7d58df0c892b08db80a567b61963c2f4df779cd0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:34:06 +0000 Subject: [PATCH 08/13] fix(client-runtime): an unanswered send stops waiting without cancelling the server's work The two-minute deadline interrupted the request itself, which makes the RPC client cancel the server handler. A thread launch commits thread creation and its first message separately, so a cut launch could leave an empty thread while the error said the message may still arrive. The request now runs detached and only the wait is bounded, so the server finishes and the error stays true. AI-assisted; verified with the tests and tools listed above. --- .../src/operations/commands.test.ts | 33 +++++++++++-------- .../client-runtime/src/operations/commands.ts | 15 ++++++--- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 76fad6b1234f3..7b279f79b9de2 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -22,6 +22,7 @@ import { import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -67,16 +68,25 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct readonly projection?: OrchestrationV2ThreadProjection; readonly projectionRequests?: ThreadId[]; readonly advertiseThreadStop?: boolean; - readonly neverAnswers?: boolean; + readonly stalled?: { readonly release: Deferred.Deferred; interrupted: boolean }; }) { + const answer = (result: A) => + input.stalled === undefined + ? Effect.succeed(result) + : Deferred.await(input.stalled.release).pipe( + Effect.as(result), + Effect.onInterrupt(() => + Effect.sync(() => { + input.stalled!.interrupted = true; + }), + ), + ); const client = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => Effect.sync(() => { input.commands.push(command); return { sequence: input.commands.length }; - }).pipe( - Effect.andThen((result) => (input.neverAnswers ? Effect.never : Effect.succeed(result))), - ), + }).pipe(Effect.andThen(answer)), [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (requestInput: { readonly threadId: ThreadId; }) => @@ -92,9 +102,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct projection: input.projection ?? v2Projection, resumed: false, }; - }).pipe( - Effect.andThen((result) => (input.neverAnswers ? Effect.never : Effect.succeed(result))), - ), + }).pipe(Effect.andThen(answer)), [WS_METHODS.projectsMutate]: (mutation: ProjectMutation) => Effect.sync(() => { input.projects.push(mutation); @@ -307,12 +315,8 @@ describe("V2 environment commands", () => { function* (bootstrap: StartThreadTurnInput["bootstrap"]) { const commands: OrchestrationV2Command[] = []; const launches: OrchestrationV2ThreadLaunchInput[] = []; - const supervisor = yield* makeSupervisor({ - commands, - launches, - projects: [], - neverAnswers: true, - }); + const stalled = { release: yield* Deferred.make(), interrupted: false }; + const supervisor = yield* makeSupervisor({ commands, launches, projects: [], stalled }); const send = yield* startThreadTurn({ commandId: CommandId.make("command-unanswered"), @@ -342,6 +346,9 @@ describe("V2 environment commands", () => { "The server did not confirm this message. It may still arrive; check the thread before sending it again.", }), ); + // A launch commits in several steps, so the late request must finish rather than be cut. + expect(stalled.interrupted).toBe(false); + yield* Deferred.succeed(stalled.release, undefined); }, ); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 4964633fbc71c..d4c545ae6f50b 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -32,6 +32,7 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import { getInitialServerConfig, request } from "../rpc/client.ts"; @@ -287,12 +288,17 @@ export class MessageDispatchUnansweredError extends Schema.TaggedError(effect: Effect.Effect) => - effect.pipe( +const requireMessageAnswer = Effect.fn("EnvironmentCommands.requireMessageAnswer")(function* < + A, + E, + R, +>(effect: Effect.Effect) { + const answer = yield* Effect.forkDetach(effect, { startImmediately: true }); + return yield* Fiber.join(answer).pipe( Effect.timeoutOrElse({ duration: MESSAGE_DISPATCH_ANSWER_TIMEOUT, orElse: () => @@ -304,6 +310,7 @@ const requireMessageAnswer = (effect: Effect.Effect) => ), }), ); +}); const dispatchMessage = (command: OrchestrationV2Command) => requireMessageAnswer(dispatch(command)); From 0ab2ad8e148867dbbb0bc032e5aab267a9a032dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:44:50 +0000 Subject: [PATCH 09/13] fix(web): an unanswered first-message launch keeps the next message in view A launch that misses its deadline may already have created the thread and promoted the draft. Restoring the failed draft un-promoted it and showed the first message in place of the one typed after it. The launch deadline now fails with ThreadLaunchUnansweredError, and that failure leaves the draft promoted. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/useComposerSend.ts | 30 ++++++++++++------- .../src/operations/commands.test.ts | 2 +- .../client-runtime/src/operations/commands.ts | 15 ++++++---- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 1e55f64a28697..c7ffda1df9ba9 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -32,7 +32,10 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; +import { + deriveThreadTitleSeed, + ThreadLaunchUnansweredError, +} from "@t3tools/client-runtime/operations"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { declinePendingApprovals, @@ -51,6 +54,7 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; +import * as Schema from "effect/Schema"; import { useNavigate } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -264,6 +268,8 @@ interface ComposerSendInput { readonly linkCheckoutPullRequest: (threadRef: ScopedThreadRef, url: string) => void; } +const isThreadLaunchUnanswered = Schema.is(ThreadLaunchUnansweredError); + const notifyQueuedSubmitNotSent = () => toastManager.add( stackedThreadToast({ @@ -1419,14 +1425,16 @@ export function useComposerSend(input: ComposerSendInput) { } if (failure !== null) { + const failureError = squashAtomCommandFailure(failure); + // An unanswered launch may still create the thread, so the draft stays promoted to it. if (isLocalDraftThread && draftId && draftThread) { - restoreFailedDraftThread( - draftId, - draftThread, - wasBootstrapThreadDeleted(squashAtomCommandFailure(failure)) - ? newThreadId() - : threadIdForSend, - ); + if (!isThreadLaunchUnanswered(failureError)) { + restoreFailedDraftThread( + draftId, + draftThread, + wasBootstrapThreadDeleted(failureError) ? newThreadId() : threadIdForSend, + ); + } clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); } // Only an acknowledged send replaces its optimistic row, so a failed one leaves here. @@ -1481,10 +1489,9 @@ export function useComposerSend(input: ComposerSendInput) { } } if (!isAtomCommandInterrupted(failure)) { - const error = squashAtomCommandFailure(failure); setThreadError( threadIdForSend, - error instanceof Error ? error.message : "Failed to send message.", + failureError instanceof Error ? failureError.message : "Failed to send message.", ); if ( draftId && @@ -1497,7 +1504,8 @@ export function useComposerSend(input: ComposerSendInput) { submissionIntent === "background" ? "Background task failed" : "Could not start thread", - description: error instanceof Error ? error.message : "Failed to send message.", + description: + failureError instanceof Error ? failureError.message : "Failed to send message.", actionProps: { children: "Open draft", onClick: () => { diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 8170a502bba3e..b28ebd97ebc27 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -505,7 +505,7 @@ describe("V2 environment commands", () => { expect(sendFiber.pollUnsafe()).toBeUndefined(); yield* TestClock.adjust("1 second"); - expect((yield* Fiber.join(sendFiber))._tag).toBe("EnvironmentRpcUnavailableError"); + expect((yield* Fiber.join(sendFiber))._tag).toBe("ThreadLaunchUnansweredError"); // The server commits a launch in several steps, so the late request keeps running. expect(interrupted).toBe(false); }).pipe(Effect.provide(layerTestRandom)), diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 2ff9317f011c1..330561d5433f2 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -36,6 +36,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -350,14 +351,18 @@ const dispatchMessage = Effect.fn("EnvironmentCommands.dispatchMessage")(functio } }); +export class ThreadLaunchUnansweredError extends Schema.TaggedError()( + "ThreadLaunchUnansweredError", + { message: Schema.String }, +) {} + // Interrupting a launch would cancel the server handler mid-launch, so the deadline bounds only -// the wait and the launch keeps running; the error says the message may still arrive. +// the wait and the launch keeps running; the server may still create the thread. const THREAD_LAUNCH_TIMEOUT = "2 minutes"; const launchThread = Effect.fn("EnvironmentCommands.launchThread")(function* ( input: EnvironmentRpcInput, ) { - const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; const answer = yield* Effect.forkDetach( request(ORCHESTRATION_V2_WS_METHODS.launchThread, input), { @@ -369,9 +374,9 @@ const launchThread = Effect.fn("EnvironmentCommands.launchThread")(function* ( duration: THREAD_LAUNCH_TIMEOUT, orElse: () => Effect.fail( - new EnvironmentRpcUnavailableError({ - environmentId: supervisor.target.environmentId, - message: `${supervisor.target.label} did not confirm the message within 2 minutes. It may still arrive; check the thread before sending it again.`, + new ThreadLaunchUnansweredError({ + message: + "The server did not confirm this message. It may still arrive; check the thread before sending it again.", }), ), }), From 451dc8c44ebceae4ce7444de22a07f497dd067f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:50:22 +0000 Subject: [PATCH 10/13] fix(web): Enter on an open question answers it while a send is in flight The deferred-Enter queue caught answers too, then cancelled them because a request was pending. An answer now goes straight to the send path, as on main. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/useComposerSend.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index c7ffda1df9ba9..ff5589f098fa3 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1548,6 +1548,10 @@ export function useComposerSend(input: ComposerSendInput) { submissionIntent = "foreground", directAnnotation, ) => { + // Enter on an open question answers it, which a send in flight must not hold back. + if (activePendingProgress && !directAnnotation) { + return sendComposerDraft(e, dispatchMode, submissionIntent); + } const queued = queuedSubmitRef.current?.routeThreadKey === routeThreadKey ? queuedSubmitRef.current : null; const behind = directAnnotation From 638cb3a335f9fd2c11198f75f23c0e8cd810ff60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:54:48 +0000 Subject: [PATCH 11/13] fix(web): an unanswered launch with no thread yet restores its draft Keeping every unanswered launch promoted stranded the draft when the server never created the thread. The draft now stays promoted only when its thread already arrived; otherwise it is restored and promotes again if the thread lands later. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/useComposerSend.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index ff5589f098fa3..282ff13d8836c 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -102,7 +102,7 @@ import { responseContextReference, } from "../../lib/composerContextRecords"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { runMobileComposerTransition } from "./draftHeroTransition"; import { useClientSettingsHydrated } from "../../hooks/useSettings"; import { useStableCallback } from "../../hooks/useStableCallback"; @@ -1426,9 +1426,15 @@ export function useComposerSend(input: ComposerSendInput) { if (failure !== null) { const failureError = squashAtomCommandFailure(failure); - // An unanswered launch may still create the thread, so the draft stays promoted to it. if (isLocalDraftThread && draftId && draftThread) { - if (!isThreadLaunchUnanswered(failureError)) { + // An unanswered launch whose thread already arrived keeps the draft promoted to it; one + // that has not arrived restores the draft, which promotes again if the thread lands. + const launchedThreadArrived = + isThreadLaunchUnanswered(failureError) && + appAtomRegistry.get( + environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadIdForSend)), + ) !== null; + if (!launchedThreadArrived) { restoreFailedDraftThread( draftId, draftThread, From a77699ce996c5e434fc187e8f9f1fc3540ceb265 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:57:04 +0000 Subject: [PATCH 12/13] fix(web): a queued Enter cancels when a request arrives before its send settles The queued submit checked for a pending approval or question only once it was ready, so a request answered elsewhere in the meantime let it send anyway. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.logic.test.ts | 3 +++ apps/web/src/components/ChatView.logic.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bea1a3cb5a966..b9609519d9d08 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1246,6 +1246,9 @@ describe("resolveQueuedComposerSubmit", () => { expect(resolveQueuedComposerSubmit(ready)).toBe("send"); expect(resolveQueuedComposerSubmit({ ...ready, sendBlocked: true })).toBe("wait"); expect(resolveQueuedComposerSubmit({ ...ready, pendingRequest: true })).toBe("cancel"); + expect(resolveQueuedComposerSubmit({ ...ready, ready: false, pendingRequest: true })).toBe( + "cancel", + ); expect(resolveQueuedComposerSubmit({ ...ready, draftUnchanged: false })).toBe("cancel"); expect(resolveQueuedComposerSubmit({ ...ready, ready: false, leftThread: true })).toBe("drop"); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 9e6d91fa3c9db..a1611943a68de 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1357,8 +1357,10 @@ export function resolveQueuedComposerSubmit(input: { readonly draftUnchanged: boolean; }): "wait" | "send" | "drop" | "cancel" { if (input.leftThread) return "drop"; + // Cancel as soon as a request shows up: another client may answer it before this one is ready. + if (input.pendingRequest) return "cancel"; if (!input.ready || input.sendBlocked) return "wait"; - return input.draftUnchanged && !input.pendingRequest ? "send" : "cancel"; + return input.draftUnchanged ? "send" : "cancel"; } /** From a6c9a96b2ee55ee279a0c6f37e8c6cc6255529c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 01:00:24 +0000 Subject: [PATCH 13/13] fix(web): an unanswered launch recovers the composer on its arrived thread Once the launched thread arrives, the promoted draft's composer content lives under that thread. Recovery read and wrote the old draft id instead, which could restore the failed message to an orphan draft. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/useComposerSend.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 282ff13d8836c..495cd84def8eb 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1426,14 +1426,16 @@ export function useComposerSend(input: ComposerSendInput) { if (failure !== null) { const failureError = squashAtomCommandFailure(failure); + // An unanswered launch whose thread already arrived keeps the draft promoted to it, and its + // composer content now lives under that thread; one that has not arrived restores the + // draft, which promotes again if the thread lands. + const launchedThreadRef = scopeThreadRef(environmentId, threadIdForSend); + const launchedThreadArrived = + isLocalDraftThread && + isThreadLaunchUnanswered(failureError) && + appAtomRegistry.get(environmentThreadShells.threadShellAtom(launchedThreadRef)) !== null; + const recoveryTarget = launchedThreadArrived ? launchedThreadRef : composerDraftTarget; if (isLocalDraftThread && draftId && draftThread) { - // An unanswered launch whose thread already arrived keeps the draft promoted to it; one - // that has not arrived restores the draft, which promotes again if the thread lands. - const launchedThreadArrived = - isThreadLaunchUnanswered(failureError) && - appAtomRegistry.get( - environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadIdForSend)), - ) !== null; if (!launchedThreadArrived) { restoreFailedDraftThread( draftId, @@ -1441,7 +1443,7 @@ export function useComposerSend(input: ComposerSendInput) { wasBootstrapThreadDeleted(failureError) ? newThreadId() : threadIdForSend, ); } - clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); + clearBackgroundDraftSubmissionByRef(launchedThreadRef); } // Only an acknowledged send replaces its optimistic row, so a failed one leaves here. setOptimisticUserMessages((existing) => { @@ -1452,7 +1454,7 @@ export function useComposerSend(input: ComposerSendInput) { optimisticBlobPreviews.release(optimisticMessage); if ( composerDraftHasUserContent( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + useComposerDraftStore.getState().getComposerDraft(recoveryTarget), ) ) { // A draft typed meanwhile keeps the composer, so the failed text stays reachable here. @@ -1478,7 +1480,7 @@ export function useComposerSend(input: ComposerSendInput) { ); } } else { - const retryComposerImages = restoreComposerDraft(composerDraftTarget, sendSnapshot); + const retryComposerImages = restoreComposerDraft(recoveryTarget, sendSnapshot); if (currentRouteThreadKeyRef.current === routeThreadKey) { promptRef.current = sendSnapshot.draftText; composerImagesRef.current = retryComposerImages;