Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
03b0168
test(client-runtime): reproduce issue 23661 unanswered send never fails
juliusmarminge Oct 24, 2026
49bab05
fix(client-runtime): a message send the server never answers fails af…
juliusmarminge Oct 24, 2026
cdb0c17
fix(web): Enter while a message is still sending sends the next one a…
juliusmarminge Oct 24, 2026
c01ade8
fix(client-runtime): a first-message thread launch the server never a…
juliusmarminge Oct 24, 2026
cfb3ad9
fix(web): a queued Enter only follows its own send and never answers …
juliusmarminge Oct 24, 2026
887f485
fix(web): a queued Enter waits behind its own thread's send after nav…
juliusmarminge Oct 24, 2026
ad85721
Merge main into fix/issue-23661
juliusmarminge Oct 24, 2026
d149db7
Merge remote-tracking branch 'origin/main' into fix/issue-23661
juliusmarminge Nov 11, 2026
5ef4585
fix(web): a queued Enter follows main's draft shape and says when an …
juliusmarminge Nov 11, 2026
7d58df0
fix(client-runtime): an unanswered send stops waiting without cancell…
juliusmarminge Nov 11, 2026
44d321d
Merge remote-tracking branch 'origin/main' into fix/issue-23661
juliusmarminge Nov 11, 2026
11d4928
Merge remote-tracking branch 'origin/main' into fix/issue-23661
juliusmarminge Nov 14, 2026
0ab2ad8
fix(web): an unanswered first-message launch keeps the next message i…
juliusmarminge Nov 14, 2026
451dc8c
fix(web): Enter on an open question answers it while a send is in flight
juliusmarminge Nov 14, 2026
638cb3a
fix(web): an unanswered launch with no thread yet restores its draft
juliusmarminge Nov 14, 2026
a77699c
fix(web): a queued Enter cancels when a request arrives before its se…
juliusmarminge Nov 14, 2026
a6c9a96
fix(web): an unanswered launch recovers the composer on its arrived t…
juliusmarminge Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ import {
deriveLockedProvider,
hasServerAcknowledgedLocalDispatch,
waitForRevertedMessage,
composerDraftContentUnchanged,
queueComposerSubmitBehind,
resolveQueuedComposerSubmit,
settleQueuedComposerSubmit,
} from "./ChatView.logic";

const environmentId = EnvironmentId.make("environment-local");
Expand Down Expand Up @@ -1129,6 +1133,127 @@ 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: [],
environmentContexts: [],
replyTo: null,
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("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 };
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", () => {
expect(
queueComposerSubmitBehind({
routeThreadKey: "thread-x",
sendInFlight: true,
runningSends: new Map([
["thread-x", 1],
["thread-y", 2],
]),
queued: null,
}),
).toEqual({ afterSend: 1, ready: false });
});

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();
});
});

describe("resolveQueuedComposerSubmit", () => {
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, 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");
});
});

describe("planComposerTurn", () => {
const base = {
messageText: "hello",
Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
import {
type ComposerFileAttachment,
type ComposerImageAttachment,
type ComposerThreadDraftState,
type DraftId,
type DraftThreadState,
} from "../composerDraftStore";
Expand Down Expand Up @@ -1260,6 +1261,108 @@ export interface ComposerSendContents {
readonly replyTo: ResponseContextRecord | null;
}

/**
* 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 &&
submitted.environmentContexts === current.environmentContexts &&
submitted.replyTo === current.replyTo
);
}

/** A draft the user pressed Enter on while their previous message was still sending. */
export interface QueuedComposerSubmit<Mode, Intent> {
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;
}

/**
* 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<string, number>;
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.
*/
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 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;
readonly leftThread: boolean;
readonly sendBlocked: boolean;
readonly pendingRequest: boolean;
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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return input.draftUnchanged ? "send" : "cancel";
}

/**
* 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
Expand Down
Loading
Loading