- {(draftImages.length > 0 || documentName) && (
+ {!queueEdit && (draftImages.length > 0 || documentName) && (
{draftImages.length > 0 && (
@@ -5248,7 +6097,6 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
removeImage(i)}
- disabled={isGenerating}
aria-label={`Remove attachment ${i + 1}`}
className="absolute -right-1 -top-1 rounded-full border bg-background p-0.5 opacity-0 transition-opacity group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-40"
>
@@ -5266,7 +6114,6 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
@@ -5284,6 +6131,13 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
)}
+
diff --git a/frontend/src/contexts/ChatRuntimeContext.test.ts b/frontend/src/contexts/ChatRuntimeContext.test.ts
index 945331c26..042d274cd 100644
--- a/frontend/src/contexts/ChatRuntimeContext.test.ts
+++ b/frontend/src/contexts/ChatRuntimeContext.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, spyOn, test } from "bun:test";
-import { createChatComposerState, createChatRuntimeStore } from "./ChatRuntimeContext";
+import {
+ composerHasRetainedDraft,
+ createChatComposerState,
+ createChatRuntimeStore
+} from "./ChatRuntimeContext";
+import { cancelActiveChatRuntimeRuns } from "@/services/chatRuntimeCancellation";
import {
draftScopeForRuntimeSelection,
moveRememberedChatDraftToScope,
@@ -8,11 +13,66 @@ import {
rootChatDraftKeyAfterProjectDeletion
} from "../services/chatDraftSelection";
import { createChatDraftKey, createConversationChatKey } from "../services/chatRuntimeStore";
+import {
+ mergeChatComposerDraftsForRekey,
+ type ChatQueuedMessage
+} from "../services/chatComposerQueue";
type Conversation = { id: string };
type Message = { id: string };
+function queuedMessage(id: string, overrides: Partial
= {}): ChatQueuedMessage {
+ return {
+ queueId: `queue-${id}`,
+ messageId: `message-${id}`,
+ text: id,
+ draftImages: [],
+ imageUrls: new Map(),
+ documentText: "",
+ documentName: "",
+ draftProjectId: null,
+ model: "maple-model",
+ webSearchEnabled: false,
+ createdMs: 0,
+ ...overrides
+ };
+}
+
describe("ChatRuntimeContext eviction policy", () => {
+ test("account teardown synchronously cancels every active queue runner", () => {
+ const store = createChatRuntimeStore();
+ const firstKey = createConversationChatKey("first-running-conversation");
+ const secondKey = createConversationChatKey("second-running-conversation");
+ store.ensure(firstKey);
+ store.ensure(secondKey);
+ const first = store.beginRun(firstKey);
+ const second = store.beginRun(secondKey);
+
+ cancelActiveChatRuntimeRuns(store);
+
+ expect(first.signal.aborted).toBe(true);
+ expect(second.signal.aborted).toBe(true);
+ expect(store.getActiveRunKeys()).toEqual([]);
+ expect(store.get(firstKey)?.composer).toBeDefined();
+ expect(store.get(secondKey)?.composer).toBeDefined();
+ });
+
+ test("retains an idle runtime whose only unsent material is its queue", () => {
+ const store = createChatRuntimeStore(0);
+ const queuedKey = createConversationChatKey("queued-conversation");
+ const nextKey = createConversationChatKey("next-conversation");
+ const composer = createChatComposerState();
+ composer.queue.items = [queuedMessage("retained")];
+
+ store.select(queuedKey, { composer });
+ store.select(nextKey, { conversation: { id: "next-conversation" } });
+
+ expect(store.get(queuedKey)?.composer.queue.items).toEqual([
+ expect.objectContaining({ queueId: "queue-retained" })
+ ]);
+ expect(composerHasRetainedDraft(queuedKey, composer)).toBe(true);
+ });
+
test("retains and restores an idle draft with unsent composer content across selection", () => {
const store = createChatRuntimeStore(0);
const draftKey = createChatDraftKey("retained-unsent-composer");
@@ -329,10 +389,19 @@ describe("ChatRuntimeContext eviction policy", () => {
const firstAccountKey = createChatDraftKey("first-account");
const secondAccountKey = createChatDraftKey("second-account");
const image = new File(["private"], "private.png", { type: "image/png" });
+ const queuedImage = new File(["queued-private"], "queued-private.png", {
+ type: "image/png"
+ });
const composer = createChatComposerState();
composer.input = "first account draft";
composer.draftImages = [image];
composer.imageUrls.set(image, "blob:first-account");
+ composer.queue.items = [
+ queuedMessage("first-account-queued", {
+ draftImages: [queuedImage],
+ imageUrls: new Map([[queuedImage, "blob:first-account-queued"]])
+ })
+ ];
try {
firstAccountStore.select(firstAccountKey, { composer });
@@ -347,6 +416,7 @@ describe("ChatRuntimeContext eviction policy", () => {
expect(run.signal.aborted).toBe(true);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:first-account");
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:first-account-queued");
expect(secondAccountStore.getRememberedDraftKey(null)).toBe(secondAccountKey);
} finally {
firstAccountStore.dispose();
@@ -412,4 +482,82 @@ describe("ChatRuntimeContext eviction policy", () => {
revokeObjectURL.mockRestore();
}
});
+
+ test("disposal revokes active and queued object URLs exactly once", () => {
+ const revokeObjectURL = spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+ const store = createChatRuntimeStore();
+ const key = createChatDraftKey("queued-object-urls");
+ const activeFile = new File(["active"], "active.png", { type: "image/png" });
+ const queuedFile = new File(["queued"], "queued.png", { type: "image/png" });
+ const composer = createChatComposerState();
+ composer.draftImages = [activeFile];
+ composer.imageUrls.set(activeFile, "blob:shared");
+ composer.queue.items = [
+ queuedMessage("with-images", {
+ draftImages: [queuedFile],
+ imageUrls: new Map([
+ [activeFile, "blob:shared"],
+ [queuedFile, "blob:queued"]
+ ])
+ })
+ ];
+
+ try {
+ store.ensure(key, { composer });
+ store.dispose();
+
+ expect(revokeObjectURL).toHaveBeenCalledTimes(2);
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:shared");
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:queued");
+ } finally {
+ store.dispose();
+ revokeObjectURL.mockRestore();
+ }
+ });
+
+ test("draft adoption merges source and destination queues without losing composer text", () => {
+ const store = createChatRuntimeStore();
+ const sourceKey = createChatDraftKey("queued-source");
+ const destinationKey = createConversationChatKey("queued-destination");
+ const sourceComposer = createChatComposerState("project-a");
+ sourceComposer.input = "source draft";
+ sourceComposer.queue.items = [queuedMessage("source", { createdMs: 20 })];
+ const destinationComposer = createChatComposerState();
+ destinationComposer.input = "destination draft";
+ destinationComposer.queue.items = [queuedMessage("destination", { createdMs: 10 })];
+
+ store.select(sourceKey, { composer: sourceComposer });
+ const run = store.beginRun(sourceKey);
+ store.ensure(destinationKey, {
+ conversation: { id: "queued-destination" },
+ composer: destinationComposer,
+ historyLoaded: true
+ });
+
+ expect(
+ store.rekeyRunAdoptingIdleDestination(
+ sourceKey,
+ destinationKey,
+ run.token,
+ (source, destination) => ({
+ ...source,
+ conversation: destination.conversation,
+ historyLoaded: destination.historyLoaded,
+ composer: mergeChatComposerDraftsForRekey(
+ source.composer,
+ destination.composer,
+ destinationKey
+ ).composer
+ })
+ )
+ ).toMatchObject({ status: "migrated", key: destinationKey });
+
+ expect(store.get(destinationKey)?.composer.input).toBe("destination draft\nsource draft");
+ expect(store.get(destinationKey)?.composer.queue.items.map((item) => item.queueId)).toEqual([
+ "queue-destination",
+ "queue-source"
+ ]);
+ expect(store.isRunCurrent(destinationKey, run.token)).toBe(true);
+ expect(store.get(sourceKey)).toBe(store.get(destinationKey));
+ });
});
diff --git a/frontend/src/contexts/ChatRuntimeContext.tsx b/frontend/src/contexts/ChatRuntimeContext.tsx
index 742baf233..d0b5c4176 100644
--- a/frontend/src/contexts/ChatRuntimeContext.tsx
+++ b/frontend/src/contexts/ChatRuntimeContext.tsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
+import { createContext, useContext, useLayoutEffect, useMemo, type ReactNode } from "react";
import {
ChatRuntimeStore,
type ChatRuntimeKey,
@@ -6,6 +6,12 @@ import {
type ChatRuntimeStoreOptions
} from "@/services/chatRuntimeStore";
import { createDeferredDisposalLifecycle } from "@/services/deferredDisposalLifecycle";
+import {
+ disposeChatComposerObjectUrls,
+ emptyChatComposerQueueState,
+ type ChatComposerQueueState
+} from "@/services/chatComposerQueue";
+import { cancelActiveChatRuntimeRuns } from "@/services/chatRuntimeCancellation";
export type ChatPaginationState = {
oldestItemId: string | undefined;
@@ -25,6 +31,7 @@ export type ChatComposerState = {
audioError: string | null;
imagePasteGeneration: number;
documentUploadGeneration: number;
+ queue: ChatComposerQueueState;
pagination: ChatPaginationState;
};
@@ -41,6 +48,7 @@ export function createChatComposerState(draftProjectId: string | null = null): C
audioError: null,
imagePasteGeneration: 0,
documentUploadGeneration: 0,
+ queue: emptyChatComposerQueueState(),
pagination: {
oldestItemId: undefined,
isLoadingOlderMessages: false,
@@ -63,16 +71,16 @@ export function composerHasRetainedDraft(
composer.draftImages.length > 0 ||
composer.documentText.length > 0 ||
composer.documentName.length > 0 ||
- composer.isProcessingDocument
+ composer.isProcessingDocument ||
+ composer.queue.items.length > 0 ||
+ composer.queue.edit !== null
);
}
function disposeComposerResources(
snapshot: ChatRuntimeSnapshot
): void {
- for (const url of snapshot.composer.imageUrls.values()) {
- URL.revokeObjectURL(url);
- }
+ disposeChatComposerObjectUrls(snapshot.composer);
}
export function createChatRuntimeStore(
@@ -103,7 +111,16 @@ export function ChatRuntimeProvider({ children }: { children: ReactNode }) {
[store]
);
- useEffect(() => disposalLifecycle.activate(), [disposalLifecycle]);
+ useLayoutEffect(() => {
+ const scheduleDisposal = disposalLifecycle.activate();
+ return () => {
+ // Account-keyed provider teardown must fence an old account's FIFO
+ // synchronously. Resource disposal stays deferred for Strict Mode replay,
+ // but no queued request may advance during that deferral window.
+ cancelActiveChatRuntimeRuns(store);
+ scheduleDisposal();
+ };
+ }, [disposalLifecycle, store]);
return {children} ;
}
diff --git a/frontend/src/services/chatAccountQueueBudget.test.ts b/frontend/src/services/chatAccountQueueBudget.test.ts
new file mode 100644
index 000000000..0bbf34bfb
--- /dev/null
+++ b/frontend/src/services/chatAccountQueueBudget.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect, test } from "bun:test";
+import { chatAccountQueueUsage, selectChatImageFilesForRetention } from "./chatAccountQueueBudget";
+import { registerChatCurrentTurn } from "./chatCurrentTurnRegistry";
+import {
+ MAX_CHAT_ACCOUNT_RETAINED_IMAGES,
+ MAX_CHAT_QUEUED_IMAGES_PER_ITEM,
+ type ChatAccountQueueUsage,
+ type ChatQueuedMessage
+} from "./chatComposerQueue";
+
+function item(id: string, file: File, documentText = ""): ChatQueuedMessage {
+ return {
+ queueId: `queue-${id}`,
+ messageId: `message-${id}`,
+ text: id,
+ draftImages: [file],
+ imageUrls: new Map([[file, `blob:${id}`]]),
+ documentText,
+ documentName: "document.txt",
+ draftProjectId: null,
+ model: "maple-model",
+ webSearchEnabled: false,
+ createdMs: 0
+ };
+}
+
+describe("chat account queue budget", () => {
+ test("counts retained drafts and queues across runtimes without double-counting files", () => {
+ const sharedFile = new File([new Uint8Array(7)], "shared.png");
+ const queued = item("queued", sharedFile, "four");
+ const store = {
+ getSnapshots: () => [
+ {
+ composer: {
+ draftImages: [sharedFile],
+ imageUrls: new Map([[sharedFile, "blob:draft"]]),
+ documentText: "abc",
+ queue: { items: [queued] }
+ }
+ },
+ {
+ composer: {
+ draftImages: [],
+ imageUrls: new Map(),
+ documentText: "",
+ queue: { items: [] }
+ }
+ }
+ ]
+ };
+
+ expect(chatAccountQueueUsage(store)).toEqual({
+ queuedMessageCount: 1,
+ attachmentBytes: 7 + 3 + 4,
+ imageCount: 1
+ });
+ });
+
+ test("keeps a popped FIFO item reserved and deduplicates it after recovery", () => {
+ const file = new File([new Uint8Array(11)], "queued.png");
+ const queued = item("active", file, "doc");
+ let recoveredItems: ChatQueuedMessage[] = [];
+ const store = {
+ getSnapshots: () => [
+ {
+ composer: {
+ draftImages: [],
+ imageUrls: new Map(),
+ documentText: "",
+ queue: { items: recoveredItems }
+ }
+ }
+ ]
+ };
+ registerChatCurrentTurn(store, 1, {
+ responseRequestStarted: () => false,
+ restoreBeforeRequest: () => true,
+ retainedPayload: queued,
+ countsTowardQueueLimit: true
+ });
+
+ expect(chatAccountQueueUsage(store)).toEqual({
+ queuedMessageCount: 1,
+ attachmentBytes: 14,
+ imageCount: 1
+ });
+ recoveredItems = [queued];
+ expect(chatAccountQueueUsage(store)).toEqual({
+ queuedMessageCount: 1,
+ attachmentBytes: 14,
+ imageCount: 1
+ });
+ });
+
+ test("selects an ordered prefix within the live-message image limit", () => {
+ const existingFiles = Array.from(
+ { length: MAX_CHAT_QUEUED_IMAGES_PER_ITEM - 1 },
+ (_, index) => new File([], `existing-${index}.png`, { type: "image/png" })
+ );
+ const first = new File([], "first.png", { type: "image/png" });
+ const second = new File([], "second.png", { type: "image/png" });
+ const accountUsage: ChatAccountQueueUsage = {
+ queuedMessageCount: 0,
+ attachmentBytes: 0,
+ imageCount: existingFiles.length
+ };
+
+ const selected = selectChatImageFilesForRetention({
+ composer: { draftImages: existingFiles, imageUrls: new Map() },
+ candidates: [first, second],
+ accountUsage
+ });
+
+ expect(selected.files).toEqual([first]);
+ expect(selected.messageLimitExceeded).toBe(true);
+ expect(selected.accountLimitExceeded).toBe(false);
+ });
+
+ test("admits only the remaining account slot, including for zero-byte images", () => {
+ const first = new File([], "first.png", { type: "image/png" });
+ const second = new File([], "second.png", { type: "image/png" });
+ const accountUsage: ChatAccountQueueUsage = {
+ queuedMessageCount: 0,
+ attachmentBytes: 0,
+ imageCount: MAX_CHAT_ACCOUNT_RETAINED_IMAGES - 1
+ };
+
+ const selected = selectChatImageFilesForRetention({
+ composer: { draftImages: [], imageUrls: new Map() },
+ candidates: [first, second],
+ accountUsage
+ });
+ const full = selectChatImageFilesForRetention({
+ composer: { draftImages: [], imageUrls: new Map() },
+ candidates: [first],
+ accountUsage: { ...accountUsage, imageCount: MAX_CHAT_ACCOUNT_RETAINED_IMAGES }
+ });
+
+ expect(selected.files).toEqual([first]);
+ expect(selected.accountLimitExceeded).toBe(true);
+ expect(selected.messageLimitExceeded).toBe(false);
+ expect(full.files).toEqual([]);
+ expect(full.accountLimitExceeded).toBe(true);
+ });
+
+ test("ignores repeated and already-owned File identities", () => {
+ const existing = new File([], "existing.png", { type: "image/png" });
+ const added = new File([], "added.png", { type: "image/png" });
+ const accountUsage: ChatAccountQueueUsage = {
+ queuedMessageCount: 0,
+ attachmentBytes: 0,
+ imageCount: 1
+ };
+
+ const selected = selectChatImageFilesForRetention({
+ composer: {
+ draftImages: [existing],
+ imageUrls: new Map([[existing, "blob:existing"]])
+ },
+ candidates: [existing, existing, added, added],
+ accountUsage
+ });
+
+ expect(selected).toEqual({
+ files: [added],
+ accountLimitExceeded: false,
+ messageLimitExceeded: false
+ });
+ });
+});
diff --git a/frontend/src/services/chatAccountQueueBudget.ts b/frontend/src/services/chatAccountQueueBudget.ts
new file mode 100644
index 000000000..a4ec45858
--- /dev/null
+++ b/frontend/src/services/chatAccountQueueBudget.ts
@@ -0,0 +1,118 @@
+import {
+ chatQueuedTextByteLength,
+ MAX_CHAT_ACCOUNT_RETAINED_IMAGES,
+ MAX_CHAT_QUEUED_IMAGES_PER_ITEM,
+ type ChatAccountQueueUsage,
+ type ChatComposerDraft,
+ type ChatQueuedMessage
+} from "./chatComposerQueue";
+import { getRegisteredChatCurrentTurnPayloads } from "./chatCurrentTurnRegistry";
+
+type RetainedChatComposer = Readonly<{
+ draftImages: readonly File[];
+ imageUrls: ReadonlyMap;
+ documentText: string;
+ queue: Readonly<{ items: readonly ChatQueuedMessage[] }>;
+}>;
+
+type ChatAccountRuntimeLookup = object & {
+ getSnapshots: () => readonly Readonly<{ composer: RetainedChatComposer }>[];
+};
+
+export type ChatImageFileSelection = Readonly<{
+ files: readonly File[];
+ accountLimitExceeded: boolean;
+ messageLimitExceeded: boolean;
+}>;
+
+function isChatQueuedMessage(payload: unknown): payload is ChatQueuedMessage {
+ return Boolean(
+ payload &&
+ typeof payload === "object" &&
+ "queueId" in payload &&
+ typeof payload.queueId === "string" &&
+ "draftImages" in payload &&
+ Array.isArray(payload.draftImages) &&
+ "imageUrls" in payload &&
+ payload.imageUrls instanceof Map &&
+ "documentText" in payload &&
+ typeof payload.documentText === "string"
+ );
+}
+
+/**
+ * Counts account-scoped attachment ownership and staged messages exactly once,
+ * including a FIFO item temporarily popped into the active send loop.
+ */
+export function chatAccountQueueUsage(store: ChatAccountRuntimeLookup): ChatAccountQueueUsage {
+ const seenFiles = new Set();
+ const seenQueueItems = new Set();
+ let queuedMessageCount = 0;
+ let documentBytes = 0;
+
+ const addFile = (file: File) => {
+ if (seenFiles.has(file)) return;
+ seenFiles.add(file);
+ };
+ const addItem = (item: ChatQueuedMessage, countsTowardQueueLimit: boolean) => {
+ if (seenQueueItems.has(item)) return;
+ seenQueueItems.add(item);
+ if (countsTowardQueueLimit) queuedMessageCount += 1;
+ documentBytes += chatQueuedTextByteLength(item.documentText);
+ for (const file of item.draftImages) addFile(file);
+ for (const file of item.imageUrls.keys()) addFile(file);
+ };
+
+ for (const { composer } of store.getSnapshots()) {
+ documentBytes += chatQueuedTextByteLength(composer.documentText);
+ for (const file of composer.draftImages) addFile(file);
+ for (const file of composer.imageUrls.keys()) addFile(file);
+ for (const item of composer.queue.items) addItem(item, true);
+ }
+ for (const current of getRegisteredChatCurrentTurnPayloads(store)) {
+ if (isChatQueuedMessage(current.payload)) {
+ addItem(current.payload, current.countsTowardQueueLimit);
+ }
+ }
+
+ let attachmentBytes = documentBytes;
+ for (const file of seenFiles) attachmentBytes += file.size;
+ return { queuedMessageCount, attachmentBytes, imageCount: seenFiles.size };
+}
+
+/**
+ * Selects the prefix of new image Files that fits both the live-message and
+ * account-wide retained-image limits. Existing and repeated File identities
+ * are ignored, so callers never need to create a replacement blob URL for an
+ * image the composer already owns.
+ */
+export function selectChatImageFilesForRetention({
+ composer,
+ candidates,
+ accountUsage
+}: {
+ composer: Pick;
+ candidates: readonly File[];
+ accountUsage: ChatAccountQueueUsage;
+}): ChatImageFileSelection {
+ const existingFiles = new Set(composer.draftImages);
+ for (const file of composer.imageUrls.keys()) existingFiles.add(file);
+
+ const uniqueCandidates: File[] = [];
+ const seenCandidates = new Set(existingFiles);
+ for (const file of candidates) {
+ if (seenCandidates.has(file)) continue;
+ seenCandidates.add(file);
+ uniqueCandidates.push(file);
+ }
+
+ const messageCapacity = Math.max(0, MAX_CHAT_QUEUED_IMAGES_PER_ITEM - existingFiles.size);
+ const accountCapacity = Math.max(0, MAX_CHAT_ACCOUNT_RETAINED_IMAGES - accountUsage.imageCount);
+ const files = uniqueCandidates.slice(0, Math.min(messageCapacity, accountCapacity));
+
+ return {
+ files,
+ messageLimitExceeded: uniqueCandidates.length > messageCapacity,
+ accountLimitExceeded: uniqueCandidates.length > accountCapacity
+ };
+}
diff --git a/frontend/src/services/chatComposerQueue.test.ts b/frontend/src/services/chatComposerQueue.test.ts
new file mode 100644
index 000000000..0527f5042
--- /dev/null
+++ b/frontend/src/services/chatComposerQueue.test.ts
@@ -0,0 +1,707 @@
+import { describe, expect, test } from "bun:test";
+import {
+ MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES,
+ MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES,
+ MAX_CHAT_QUEUED_AGGREGATE_ATTACHMENT_BYTES,
+ MAX_CHAT_QUEUED_DOCUMENT_BYTES,
+ MAX_CHAT_QUEUED_IMAGE_BYTES,
+ MAX_CHAT_QUEUED_IMAGES_PER_ITEM,
+ MAX_CHAT_QUEUED_MESSAGES,
+ MAX_CHAT_QUEUED_TEXT_BYTES,
+ beginChatQueuedMessageEdit,
+ cancelChatQueuedMessage,
+ chatComposerObjectUrls,
+ detachChatComposerDraft,
+ discardChatQueuedMessageEdit,
+ emptyChatComposerQueueState,
+ enqueueChatQueuedMessage,
+ mergeChatComposerDraftsForRekey,
+ queuedChatMessageEditStillPresent,
+ recoverDetachedChatComposerDraft,
+ stageChatComposerDraft,
+ takeNextChatQueuedMessage,
+ updateChatQueuedMessage,
+ type ChatComposerDraft,
+ type ChatComposerQueueState,
+ type ChatQueuedMessage
+} from "./chatComposerQueue";
+
+function queuedMessage(
+ id: string,
+ text = id,
+ overrides: Partial = {}
+): ChatQueuedMessage {
+ return {
+ queueId: `queue-${id}`,
+ messageId: `message-${id}`,
+ text,
+ draftImages: [],
+ imageUrls: new Map(),
+ documentText: "",
+ documentName: "",
+ draftProjectId: null,
+ model: "maple-model",
+ webSearchEnabled: false,
+ createdMs: 0,
+ ...overrides
+ };
+}
+
+function composer(input = "", overrides: Partial = {}): ChatComposerDraft {
+ return {
+ input,
+ draftImages: [],
+ imageUrls: new Map(),
+ documentText: "",
+ documentName: "",
+ draftProjectId: null,
+ isProcessingDocument: false,
+ imagePasteGeneration: 0,
+ documentUploadGeneration: 0,
+ queue: emptyChatComposerQueueState(),
+ ...overrides
+ };
+}
+
+function queueWith(...items: ChatQueuedMessage[]): ChatComposerQueueState {
+ return { items, edit: null };
+}
+
+function imageWithSize(name: string, size: number): File {
+ const file = new File([], name, { type: "image/png" });
+ Object.defineProperty(file, "size", { value: size });
+ return file;
+}
+
+describe("chat composer queue", () => {
+ test("takes messages in FIFO order", () => {
+ let queue = emptyChatComposerQueueState();
+ for (const item of [queuedMessage("first"), queuedMessage("second")]) {
+ const result = enqueueChatQueuedMessage(queue, item);
+ expect(result.status).toBe("enqueued");
+ if (result.status === "enqueued") queue = result.queue;
+ }
+
+ const first = takeNextChatQueuedMessage(queue);
+ expect(first.status).toBe("taken");
+ if (first.status !== "taken") throw new Error("expected the first queued message");
+ expect(first.item.queueId).toBe("queue-first");
+
+ const second = takeNextChatQueuedMessage(first.queue);
+ expect(second.status).toBe("taken");
+ if (second.status !== "taken") throw new Error("expected the second queued message");
+ expect(second.item.queueId).toBe("queue-second");
+ expect(takeNextChatQueuedMessage(second.queue).status).toBe("empty");
+ });
+
+ test("enforces the normal item limit and UTF-8 staged-text limit", () => {
+ let queue = emptyChatComposerQueueState();
+ for (let index = 0; index < MAX_CHAT_QUEUED_MESSAGES; index += 1) {
+ const result = enqueueChatQueuedMessage(queue, queuedMessage(String(index)));
+ expect(result.status).toBe("enqueued");
+ if (result.status === "enqueued") queue = result.queue;
+ }
+
+ const full = enqueueChatQueuedMessage(queue, queuedMessage("overflow"));
+ expect(full.status).toBe("queue_full");
+ expect(full.queue).toBe(queue);
+
+ const exactUtf8Limit = "é".repeat(MAX_CHAT_QUEUED_TEXT_BYTES / 2);
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("exact", exactUtf8Limit)
+ ).status
+ ).toBe("enqueued");
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("too-large", `${exactUtf8Limit}a`)
+ ).status
+ ).toBe("text_too_large");
+ });
+
+ test("enforces account-wide queued-message and retained-attachment budgets", () => {
+ const queue = emptyChatComposerQueueState();
+ const candidate = queuedMessage("account-candidate");
+ expect(
+ enqueueChatQueuedMessage(queue, candidate, {
+ queuedMessageCount: MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES,
+ attachmentBytes: 0,
+ imageCount: 0
+ }).status
+ ).toBe("account_queue_full");
+ expect(
+ enqueueChatQueuedMessage(queue, candidate, {
+ queuedMessageCount: 0,
+ attachmentBytes: MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES + 1,
+ imageCount: 0
+ }).status
+ ).toBe("account_payload_too_large");
+ });
+
+ test("enforces per-item image and UTF-8 document payload limits", () => {
+ const exactImages = Array.from({ length: MAX_CHAT_QUEUED_IMAGES_PER_ITEM }, (_, index) =>
+ imageWithSize(`exact-count-${index}.png`, 1)
+ );
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("exact-image-count", "", { draftImages: exactImages })
+ ).status
+ ).toBe("enqueued");
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("too-many-images", "", {
+ draftImages: [...exactImages, imageWithSize("one-too-many.png", 1)]
+ })
+ ).status
+ ).toBe("too_many_images");
+ const mapOnlyImages = Array.from({ length: MAX_CHAT_QUEUED_IMAGES_PER_ITEM + 1 }, (_, index) =>
+ imageWithSize(`map-only-${index}.png`, 1)
+ );
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("too-many-map-owned-images", "text", {
+ imageUrls: new Map(mapOnlyImages.map((image) => [image, `blob:${image.name}`]))
+ })
+ ).status
+ ).toBe("too_many_images");
+
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("exact-image-size", "", {
+ draftImages: [imageWithSize("exact-size.png", MAX_CHAT_QUEUED_IMAGE_BYTES)]
+ })
+ ).status
+ ).toBe("enqueued");
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("large-image", "", {
+ draftImages: [imageWithSize("too-large.png", MAX_CHAT_QUEUED_IMAGE_BYTES + 1)]
+ })
+ ).status
+ ).toBe("image_too_large");
+
+ const exactDocument = "é".repeat(MAX_CHAT_QUEUED_DOCUMENT_BYTES / 2);
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("exact-document", "", { documentText: exactDocument })
+ ).status
+ ).toBe("enqueued");
+ expect(
+ enqueueChatQueuedMessage(
+ emptyChatComposerQueueState(),
+ queuedMessage("large-document", "", { documentText: `${exactDocument}d` })
+ ).status
+ ).toBe("document_too_large");
+ });
+
+ test("accepts the exact aggregate attachment limit and rejects one byte more", () => {
+ const mebibyte = 1024 * 1024;
+ const existing = queuedMessage("aggregate-existing", "", {
+ draftImages: Array.from({ length: MAX_CHAT_QUEUED_IMAGES_PER_ITEM }, (_, index) =>
+ imageWithSize(`existing-${index}.png`, MAX_CHAT_QUEUED_IMAGE_BYTES)
+ ),
+ documentText: "d".repeat(MAX_CHAT_QUEUED_DOCUMENT_BYTES)
+ });
+ const initialQueue = queueWith(existing);
+ const exactCandidate = queuedMessage("aggregate-exact", "", {
+ draftImages: [
+ imageWithSize("exact-a.png", 18 * mebibyte),
+ imageWithSize("exact-b.png", 18 * mebibyte)
+ ],
+ documentText: "d".repeat(10 * mebibyte)
+ });
+
+ expect(
+ 10 * MAX_CHAT_QUEUED_IMAGE_BYTES +
+ MAX_CHAT_QUEUED_DOCUMENT_BYTES +
+ 36 * mebibyte +
+ 10 * mebibyte
+ ).toBe(MAX_CHAT_QUEUED_AGGREGATE_ATTACHMENT_BYTES);
+ expect(enqueueChatQueuedMessage(initialQueue, exactCandidate).status).toBe("enqueued");
+
+ const overCandidate = queuedMessage("aggregate-over", "", {
+ draftImages: [
+ imageWithSize("over-a.png", 18 * mebibyte),
+ imageWithSize("over-b.png", 18 * mebibyte + 1)
+ ],
+ documentText: "d".repeat(10 * mebibyte)
+ });
+ expect(enqueueChatQueuedMessage(initialQueue, overCandidate)).toMatchObject({
+ status: "queue_payload_too_large",
+ queue: initialQueue
+ });
+ });
+
+ test("holds FIFO promotion during an edit and restores the stashed text", () => {
+ const first = queuedMessage("first", "first text");
+ const second = queuedMessage("second", "second text");
+ const started = beginChatQueuedMessageEdit(
+ queueWith(first, second),
+ "conversation:one",
+ second.queueId,
+ "new draft"
+ );
+
+ expect(started.status).toBe("started");
+ if (started.status !== "started") throw new Error("expected edit to start");
+ expect(started.input).toBe("second text");
+ expect(started.queue.edit).toEqual({
+ scopeKey: "conversation:one",
+ queueId: second.queueId,
+ stashedDraft: "new draft"
+ });
+ expect(queuedChatMessageEditStillPresent(started.queue)).toBe(true);
+ expect(takeNextChatQueuedMessage(started.queue).status).toBe("blocked_by_edit");
+
+ const updated = updateChatQueuedMessage(started.queue, second.queueId, " revised text ");
+ expect(updated.status).toBe("updated");
+ if (updated.status !== "updated") throw new Error("expected edit to update");
+ expect(updated.queue.items.map((item) => item.text)).toEqual(["first text", "revised text"]);
+ expect(updated.restoreInput).toBe("new draft");
+ expect(updated.queue.edit).toBeNull();
+ expect(takeNextChatQueuedMessage(updated.queue)).toMatchObject({
+ status: "taken",
+ item: { queueId: first.queueId }
+ });
+ });
+
+ test("queued text edits accept the exact UTF-8 limit and reject one byte more", () => {
+ const item = queuedMessage("bounded-edit", "original");
+ const edit = {
+ scopeKey: "conversation:bounded-edit",
+ queueId: item.queueId,
+ stashedDraft: "draft"
+ };
+ const queue = { items: [item], edit };
+ const exact = "é".repeat(MAX_CHAT_QUEUED_TEXT_BYTES / 2);
+
+ expect(updateChatQueuedMessage(queue, item.queueId, exact).status).toBe("updated");
+ expect(updateChatQueuedMessage(queue, item.queueId, `${exact}a`).status).toBe("text_too_large");
+ });
+
+ test("switching edits keeps the original stash and cancel restores it", () => {
+ const first = queuedMessage("first", "first text");
+ const second = queuedMessage("second", "second text");
+ const initial = beginChatQueuedMessageEdit(
+ queueWith(first, second),
+ "conversation:one",
+ first.queueId,
+ "original draft"
+ );
+ if (initial.status !== "started") throw new Error("expected initial edit");
+ const switched = beginChatQueuedMessageEdit(
+ initial.queue,
+ "conversation:one",
+ second.queueId,
+ initial.input
+ );
+ if (switched.status !== "started") throw new Error("expected switched edit");
+ expect(switched.queue.edit?.stashedDraft).toBe("original draft");
+
+ const cancelled = cancelChatQueuedMessage(switched.queue, second.queueId);
+ expect(cancelled.status).toBe("cancelled");
+ if (cancelled.status !== "cancelled") throw new Error("expected cancel");
+ expect(cancelled.restoreInput).toBe("original draft");
+ expect(cancelled.queue.items).toEqual([first]);
+ expect(cancelled.queue.edit).toBeNull();
+ });
+
+ test("discarding an edit restores its draft without changing queue order", () => {
+ const item = queuedMessage("edit", "queued text");
+ const started = beginChatQueuedMessageEdit(
+ queueWith(item),
+ "conversation:one",
+ item.queueId,
+ "stashed"
+ );
+ if (started.status !== "started") throw new Error("expected edit");
+
+ expect(discardChatQueuedMessageEdit(started.queue)).toEqual({
+ status: "ended",
+ queue: queueWith(item),
+ restoreInput: "stashed"
+ });
+ });
+
+ test("detaches every send-owned field and stages without mutating the source draft", () => {
+ const image = new File(["image"], "draft.png", { type: "image/png" });
+ const original = composer(" prompt with context ", {
+ draftImages: [image],
+ imageUrls: new Map([[image, "blob:draft-image"]]),
+ documentText: "document payload",
+ documentName: "notes.md",
+ draftProjectId: "project-a",
+ imagePasteGeneration: 4,
+ documentUploadGeneration: 8
+ });
+ const metadata = {
+ queueId: "queue-detached",
+ messageId: "message-detached",
+ model: "model-at-submit",
+ webSearchEnabled: true,
+ createdMs: 42
+ };
+
+ const detached = detachChatComposerDraft(original, metadata);
+ expect(detached.item).toMatchObject({
+ ...metadata,
+ text: "prompt with context",
+ documentText: "document payload",
+ documentName: "notes.md",
+ draftProjectId: "project-a"
+ });
+ expect(detached.item.draftImages).toEqual([image]);
+ expect(detached.item.imageUrls.get(image)).toBe("blob:draft-image");
+ expect(detached.composer).toMatchObject({
+ input: "",
+ draftImages: [],
+ documentText: "",
+ documentName: "",
+ draftProjectId: "project-a",
+ imagePasteGeneration: 5,
+ documentUploadGeneration: 9
+ });
+ expect(detached.composer.imageUrls.size).toBe(0);
+ expect(original.input).toBe(" prompt with context ");
+ expect(original.draftImages).toEqual([image]);
+ expect(original.imageUrls.get(image)).toBe("blob:draft-image");
+
+ const staged = stageChatComposerDraft(original, metadata);
+ expect(staged.status).toBe("enqueued");
+ if (staged.status !== "enqueued") throw new Error("expected staged draft");
+ expect(staged.composer.queue.items).toEqual([staged.item]);
+ expect(staged.item.model).toBe("model-at-submit");
+ expect(staged.item.webSearchEnabled).toBe(true);
+ });
+
+ test("a rejected stage returns the exact draft and leaves its resources attached", () => {
+ const image = new File(["image"], "kept.png", { type: "image/png" });
+ const fullQueue = queueWith(
+ ...Array.from({ length: MAX_CHAT_QUEUED_MESSAGES }, (_, index) =>
+ queuedMessage(`existing-${index}`)
+ )
+ );
+ const original = composer("keep me", {
+ draftImages: [image],
+ imageUrls: new Map([[image, "blob:kept"]]),
+ queue: fullQueue
+ });
+ const result = stageChatComposerDraft(original, {
+ queueId: "queue-rejected",
+ messageId: "message-rejected",
+ model: "model",
+ webSearchEnabled: false,
+ createdMs: 1
+ });
+
+ expect(result.status).toBe("queue_full");
+ expect(result.composer).toBe(original);
+ expect(result.composer.imageUrls.get(image)).toBe("blob:kept");
+ });
+
+ test("stage propagates attachment-limit failures without detaching the draft", () => {
+ const images = Array.from({ length: MAX_CHAT_QUEUED_IMAGES_PER_ITEM + 1 }, (_, index) =>
+ imageWithSize(`stage-${index}.png`, 1)
+ );
+ const original = composer("keep staged payload", { draftImages: images });
+ const result = stageChatComposerDraft(original, {
+ queueId: "queue-attachment-rejected",
+ messageId: "message-attachment-rejected",
+ model: "model",
+ webSearchEnabled: false,
+ createdMs: 1
+ });
+
+ expect(result.status).toBe("too_many_images");
+ expect(result.composer).toBe(original);
+ expect(result.composer.draftImages).toBe(images);
+ });
+
+ test("recovers failed detached sends in place or in a temporary seventeenth slot", () => {
+ const image = new File(["image"], "failed.png", { type: "image/png" });
+ const failed = queuedMessage("failed", "failed prompt", {
+ draftImages: [image],
+ imageUrls: new Map([[image, "blob:failed"]]),
+ documentText: "failed document",
+ documentName: "failed.md",
+ draftProjectId: "project-a"
+ });
+
+ const restored = recoverDetachedChatComposerDraft(composer(), failed);
+ expect(restored.status).toBe("restored");
+ expect(restored.composer).toMatchObject({
+ input: "failed prompt",
+ documentText: "failed document",
+ documentName: "failed.md",
+ draftProjectId: "project-a"
+ });
+ expect(restored.composer.draftImages).toEqual([image]);
+ expect(restored.composer.imageUrls.get(image)).toBe("blob:failed");
+
+ const fullQueue = queueWith(
+ ...Array.from({ length: MAX_CHAT_QUEUED_MESSAGES }, (_, index) =>
+ queuedMessage(`existing-${index}`)
+ )
+ );
+ const requeued = recoverDetachedChatComposerDraft(
+ composer("a newer draft", { queue: fullQueue }),
+ failed
+ );
+ expect(requeued.status).toBe("requeued");
+ expect(requeued.composer.queue.items).toHaveLength(MAX_CHAT_QUEUED_MESSAGES + 1);
+ expect(requeued.composer.queue.items[0]).toBe(failed);
+ expect(
+ enqueueChatQueuedMessage(
+ requeued.composer.queue,
+ queuedMessage("normal-enqueue-remains-blocked")
+ ).status
+ ).toBe("queue_full");
+ expect(recoverDetachedChatComposerDraft(requeued.composer, failed).status).toBe(
+ "already_queued"
+ );
+ });
+
+ test("recovery and rekey remain lossless over aggregate bounds but block new admission", () => {
+ const existing = queuedMessage("recovery-existing", "", {
+ draftImages: Array.from({ length: 10 }, (_, index) =>
+ imageWithSize(`recovery-existing-${index}.png`, MAX_CHAT_QUEUED_IMAGE_BYTES)
+ ),
+ documentText: "d".repeat(MAX_CHAT_QUEUED_DOCUMENT_BYTES)
+ });
+ const failed = queuedMessage("recovery-failed", "", {
+ draftImages: Array.from({ length: 3 }, (_, index) =>
+ imageWithSize(`recovery-failed-${index}.png`, MAX_CHAT_QUEUED_IMAGE_BYTES)
+ )
+ });
+ const recovered = recoverDetachedChatComposerDraft(
+ composer("newer draft", { queue: queueWith(existing) }),
+ failed
+ );
+
+ expect(recovered.status).toBe("requeued");
+ expect(recovered.composer.queue.items).toEqual([failed, existing]);
+ expect(
+ enqueueChatQueuedMessage(recovered.composer.queue, queuedMessage("blocked-after-recovery"))
+ .status
+ ).toBe("queue_payload_too_large");
+
+ const sourceItem = queuedMessage("oversized-source", "", {
+ draftImages: Array.from({ length: 7 }, (_, index) =>
+ imageWithSize(`source-${index}.png`, MAX_CHAT_QUEUED_IMAGE_BYTES)
+ )
+ });
+ const destinationItem = queuedMessage("oversized-destination", "", {
+ draftImages: Array.from({ length: 7 }, (_, index) =>
+ imageWithSize(`destination-${index}.png`, MAX_CHAT_QUEUED_IMAGE_BYTES)
+ )
+ });
+ const merged = mergeChatComposerDraftsForRekey(
+ composer("", { queue: queueWith(sourceItem) }),
+ composer("", { queue: queueWith(destinationItem) }),
+ "conversation:oversized"
+ );
+
+ expect(merged.composer.queue.items).toHaveLength(2);
+ expect(merged.composer.queue.items).toContain(sourceItem);
+ expect(merged.composer.queue.items).toContain(destinationItem);
+ expect(
+ enqueueChatQueuedMessage(merged.composer.queue, queuedMessage("blocked-after-rekey")).status
+ ).toBe("queue_payload_too_large");
+ });
+
+ test("rekey preserves a source-only edit as the sole promotion fence", () => {
+ const sourceItem = queuedMessage("source-edit", "original queued text", {
+ queueId: "collision",
+ messageId: "message-collision",
+ createdMs: 10
+ });
+ const destinationItem = queuedMessage("destination", "destination queued text", {
+ queueId: "collision",
+ messageId: "message-collision",
+ createdMs: 20
+ });
+ const oversizedEdit = "x".repeat(MAX_CHAT_QUEUED_TEXT_BYTES + 1);
+ const source = composer(oversizedEdit, {
+ queue: {
+ items: [sourceItem],
+ edit: {
+ scopeKey: "draft:source",
+ queueId: sourceItem.queueId,
+ stashedDraft: "source stashed draft"
+ }
+ }
+ });
+ const destination = composer("destination draft", {
+ queue: queueWith(destinationItem)
+ });
+
+ const merged = mergeChatComposerDraftsForRekey(source, destination, "conversation:created");
+
+ expect(merged.composer.input).toBe(oversizedEdit);
+ expect(merged.composer.queue.edit).toEqual({
+ scopeKey: "conversation:created",
+ queueId: "collision:rekey:2",
+ stashedDraft: "destination draft\nsource stashed draft"
+ });
+ expect(
+ merged.composer.queue.items.find((item) => item.queueId === "collision:rekey:2")?.text
+ ).toBe("original queued text");
+ expect(takeNextChatQueuedMessage(merged.composer.queue).status).toBe("blocked_by_edit");
+ expect(
+ updateChatQueuedMessage(
+ merged.composer.queue,
+ merged.composer.queue.edit!.queueId,
+ merged.composer.input
+ ).status
+ ).toBe("text_too_large");
+ });
+
+ test("rekey merge preserves both drafts and queues while the destination edit stays visible", () => {
+ const sharedImage = new File(["shared"], "shared.png", { type: "image/png" });
+ const sourceOnlyImage = new File(["source"], "source.png", { type: "image/png" });
+ const destinationCollision = queuedMessage("destination-collision", "destination item", {
+ queueId: "collision",
+ messageId: "message-collision",
+ createdMs: 20,
+ draftProjectId: "destination-project"
+ });
+ const destinationEditItem = queuedMessage("destination-edit", "destination queued", {
+ createdMs: 40
+ });
+ const sourceCollision = queuedMessage("source-collision", "source queued before edit", {
+ queueId: "collision",
+ messageId: "message-collision",
+ createdMs: 10,
+ draftProjectId: "source-project"
+ });
+ const sourceLater = queuedMessage("source-later", "source later", { createdMs: 30 });
+ const source = composer("source edited queue text", {
+ draftImages: [sharedImage, sourceOnlyImage],
+ imageUrls: new Map([
+ [sharedImage, "blob:source-shared"],
+ [sourceOnlyImage, "blob:source-only"]
+ ]),
+ documentText: "source document",
+ documentName: "source.md",
+ draftProjectId: "source-project",
+ queue: {
+ items: [sourceCollision, sourceLater],
+ edit: {
+ scopeKey: "draft:source",
+ queueId: sourceCollision.queueId,
+ stashedDraft: "source stashed draft"
+ }
+ }
+ });
+ const destination = composer("destination edited queue text", {
+ draftImages: [sharedImage],
+ imageUrls: new Map([[sharedImage, "blob:destination-shared"]]),
+ documentText: "destination document",
+ documentName: "destination.md",
+ draftProjectId: "destination-project",
+ queue: {
+ items: [destinationCollision, destinationEditItem],
+ edit: {
+ scopeKey: "conversation:destination",
+ queueId: destinationEditItem.queueId,
+ stashedDraft: "destination stashed draft"
+ }
+ }
+ });
+
+ const merged = mergeChatComposerDraftsForRekey(source, destination, "conversation:merged");
+
+ expect(merged.composer.input).toBe("destination edited queue text");
+ expect(merged.composer.queue.edit).toEqual({
+ scopeKey: "conversation:merged",
+ queueId: destinationEditItem.queueId,
+ stashedDraft: "destination stashed draft\nsource stashed draft\nsource edited queue text"
+ });
+ expect(merged.composer.queue.items).toHaveLength(4);
+ expect(merged.composer.queue.items.map((item) => item.createdMs)).toEqual([10, 20, 30, 40]);
+ expect(new Set(merged.composer.queue.items.map((item) => item.queueId)).size).toBe(4);
+ expect(new Set(merged.composer.queue.items.map((item) => item.messageId)).size).toBe(4);
+ expect(merged.composer.queue.items.find((item) => item.createdMs === 10)?.text).toBe(
+ "source queued before edit"
+ );
+ expect(merged.composer.queue.items.find((item) => item.createdMs === 10)?.draftProjectId).toBe(
+ "source-project"
+ );
+ expect(merged.composer.draftImages).toEqual([sharedImage, sourceOnlyImage]);
+ expect(merged.composer.imageUrls.get(sharedImage)).toBe("blob:destination-shared");
+ expect(merged.composer.imageUrls.get(sourceOnlyImage)).toBe("blob:source-only");
+ expect(merged.displacedObjectUrls).toEqual(["blob:source-shared"]);
+ expect(merged.composer.documentText).toBe("destination document\n\nsource document");
+ expect(merged.composer.documentName).toBe("destination.md, source.md");
+ expect(merged.composer.draftProjectId).toBe("destination-project");
+ expect(takeNextChatQueuedMessage(merged.composer.queue).status).toBe("blocked_by_edit");
+
+ const resolved = updateChatQueuedMessage(
+ merged.composer.queue,
+ destinationEditItem.queueId,
+ merged.composer.input
+ );
+ expect(resolved.status).toBe("updated");
+ if (resolved.status !== "updated") throw new Error("expected explicit edit resolution");
+ expect(resolved.restoreInput).toBe(
+ "destination stashed draft\nsource stashed draft\nsource edited queue text"
+ );
+ expect(resolved.queue.items.some((item) => item.text === "source edited queue text")).toBe(
+ false
+ );
+ expect(takeNextChatQueuedMessage(resolved.queue).status).toBe("taken");
+ });
+
+ test("collects active and queued object URLs once", () => {
+ const active = new File(["active"], "active.png", { type: "image/png" });
+ const queued = new File(["queued"], "queued.png", { type: "image/png" });
+ const draft = composer("", {
+ draftImages: [active],
+ imageUrls: new Map([[active, "blob:shared"]]),
+ queue: queueWith(
+ queuedMessage("queued", "", {
+ draftImages: [queued],
+ imageUrls: new Map([
+ [queued, "blob:shared"],
+ [active, "blob:queued-only"]
+ ])
+ })
+ )
+ });
+
+ expect(chatComposerObjectUrls(draft)).toEqual(["blob:shared", "blob:queued-only"]);
+ });
+
+ test("does not displace an active URL that a merged queued item still owns", () => {
+ const sharedActiveFile = new File(["active"], "active.png", { type: "image/png" });
+ const queuedFile = new File(["queued"], "queued.png", { type: "image/png" });
+ const source = composer("", {
+ draftImages: [sharedActiveFile],
+ imageUrls: new Map([[sharedActiveFile, "blob:source-active"]]),
+ queue: queueWith(
+ queuedMessage("retains-url", "", {
+ draftImages: [queuedFile],
+ imageUrls: new Map([[queuedFile, "blob:source-active"]])
+ })
+ )
+ });
+ const destination = composer("", {
+ draftImages: [sharedActiveFile],
+ imageUrls: new Map([[sharedActiveFile, "blob:destination-active"]])
+ });
+
+ const merged = mergeChatComposerDraftsForRekey(source, destination, "conversation:destination");
+
+ expect(merged.composer.imageUrls.get(sharedActiveFile)).toBe("blob:destination-active");
+ expect(chatComposerObjectUrls(merged.composer)).toContain("blob:source-active");
+ expect(merged.displacedObjectUrls).toEqual([]);
+ });
+});
diff --git a/frontend/src/services/chatComposerQueue.ts b/frontend/src/services/chatComposerQueue.ts
new file mode 100644
index 000000000..ac70e9bd6
--- /dev/null
+++ b/frontend/src/services/chatComposerQueue.ts
@@ -0,0 +1,681 @@
+import {
+ beginQueuedMessageEdit as beginSharedQueuedMessageEdit,
+ discardQueuedMessageEdit as discardSharedQueuedMessageEdit,
+ queuedMessageEditStillPresent as sharedQueuedMessageEditStillPresent,
+ type QueuedMessageEdit
+} from "./composerQueue";
+
+export const MAX_CHAT_QUEUED_MESSAGES = 16;
+export const MAX_CHAT_QUEUED_TEXT_BYTES = 32 * 1024;
+export const MAX_CHAT_QUEUED_IMAGES_PER_ITEM = 10;
+export const MAX_CHAT_QUEUED_IMAGE_BYTES = 20 * 1024 * 1024;
+export const MAX_CHAT_QUEUED_DOCUMENT_BYTES = 10 * 1024 * 1024;
+export const MAX_CHAT_QUEUED_AGGREGATE_ATTACHMENT_BYTES = 256 * 1024 * 1024;
+export const MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES = 64;
+export const MAX_CHAT_ACCOUNT_RETAINED_IMAGES =
+ MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES * MAX_CHAT_QUEUED_IMAGES_PER_ITEM;
+export const MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES = 256 * 1024 * 1024;
+
+export type ChatQueuedMessage = {
+ queueId: string;
+ messageId: string;
+ text: string;
+ draftImages: File[];
+ imageUrls: Map;
+ documentText: string;
+ documentName: string;
+ draftProjectId: string | null;
+ model: string;
+ webSearchEnabled: boolean;
+ createdMs: number;
+};
+
+export type ChatQueuedMessageEdit = QueuedMessageEdit;
+
+export type ChatComposerQueueState = {
+ items: ChatQueuedMessage[];
+ edit: ChatQueuedMessageEdit | null;
+};
+
+export type ChatQueuedMessageMetadata = Pick<
+ ChatQueuedMessage,
+ "queueId" | "messageId" | "model" | "webSearchEnabled" | "createdMs"
+>;
+
+export type ChatComposerDraft = {
+ input: string;
+ draftImages: File[];
+ imageUrls: Map;
+ documentText: string;
+ documentName: string;
+ draftProjectId: string | null;
+ isProcessingDocument: boolean;
+ imagePasteGeneration: number;
+ documentUploadGeneration: number;
+ queue: ChatComposerQueueState;
+};
+
+export type ChatQueueAdmissionFailureStatus =
+ | "empty"
+ | "queue_full"
+ | "text_too_large"
+ | "too_many_images"
+ | "image_too_large"
+ | "document_too_large"
+ | "queue_payload_too_large"
+ | "account_queue_full"
+ | "account_payload_too_large";
+
+export type ChatAccountQueueUsage = Readonly<{
+ queuedMessageCount: number;
+ attachmentBytes: number;
+ imageCount: number;
+}>;
+
+export type EnqueueChatQueuedMessageResult =
+ | Readonly<{ status: "enqueued"; queue: ChatComposerQueueState }>
+ | Readonly<{
+ status: ChatQueueAdmissionFailureStatus;
+ queue: ChatComposerQueueState;
+ }>;
+
+export type CancelChatQueuedMessageResult =
+ | Readonly<{
+ status: "cancelled";
+ queue: ChatComposerQueueState;
+ item: ChatQueuedMessage;
+ restoreInput: string | undefined;
+ }>
+ | Readonly<{ status: "missing"; queue: ChatComposerQueueState }>;
+
+export type BeginChatQueuedMessageEditResult =
+ | Readonly<{
+ status: "started";
+ queue: ChatComposerQueueState;
+ input: string;
+ }>
+ | Readonly<{
+ status: "already_editing" | "missing";
+ queue: ChatComposerQueueState;
+ }>;
+
+export type EndChatQueuedMessageEditResult =
+ | Readonly<{
+ status: "ended";
+ queue: ChatComposerQueueState;
+ restoreInput: string;
+ }>
+ | Readonly<{ status: "not_editing"; queue: ChatComposerQueueState }>;
+
+export type UpdateChatQueuedMessageResult =
+ | Readonly<{
+ status: "updated";
+ queue: ChatComposerQueueState;
+ item: ChatQueuedMessage;
+ restoreInput: string | undefined;
+ }>
+ | Readonly<{ status: "empty"; queue: ChatComposerQueueState }>
+ | Readonly<{ status: "missing"; queue: ChatComposerQueueState }>
+ | Readonly<{ status: "text_too_large"; queue: ChatComposerQueueState }>;
+
+export type TakeNextChatQueuedMessageResult =
+ | Readonly<{
+ status: "taken";
+ queue: ChatComposerQueueState;
+ item: ChatQueuedMessage;
+ }>
+ | Readonly<{ status: "blocked_by_edit" | "empty"; queue: ChatComposerQueueState }>;
+
+export type StageChatComposerDraftResult =
+ | Readonly<{
+ status: "enqueued";
+ composer: TComposer;
+ item: ChatQueuedMessage;
+ }>
+ | Readonly<{
+ status: ChatQueueAdmissionFailureStatus;
+ composer: TComposer;
+ }>;
+
+export type RecoverDetachedChatComposerDraftResult = Readonly<{
+ status: "restored" | "requeued" | "already_queued";
+ composer: TComposer;
+}>;
+
+export type MergeChatComposerDraftsResult = Readonly<{
+ composer: TComposer;
+ displacedObjectUrls: string[];
+}>;
+
+export function emptyChatComposerQueueState(): ChatComposerQueueState {
+ return { items: [], edit: null };
+}
+
+export function chatQueuedTextByteLength(text: string): number {
+ return new TextEncoder().encode(text).byteLength;
+}
+
+export function chatQueuedMessageHasContent(item: ChatQueuedMessage): boolean {
+ return Boolean(item.text.trim() || item.draftImages.length || item.documentText);
+}
+
+export function chatComposerHasDraftMaterial(composer: ChatComposerDraft): boolean {
+ return Boolean(
+ composer.input.length ||
+ composer.draftImages.length ||
+ composer.documentText.length ||
+ composer.documentName.length ||
+ composer.isProcessingDocument
+ );
+}
+
+function queuedMessageImages(item: ChatQueuedMessage): File[] {
+ const images = new Set(item.draftImages);
+ for (const image of item.imageUrls.keys()) images.add(image);
+ return Array.from(images);
+}
+
+export function chatQueuedMessageAttachmentByteLength(item: ChatQueuedMessage): number {
+ let bytes = chatQueuedTextByteLength(item.documentText);
+ for (const image of queuedMessageImages(item)) bytes += image.size;
+ return bytes;
+}
+
+function queuedAttachmentByteLength(items: readonly ChatQueuedMessage[]): number {
+ let bytes = 0;
+ for (const item of items) bytes += chatQueuedMessageAttachmentByteLength(item);
+ return bytes;
+}
+
+function validateQueuedMessagePayload(
+ queue: ChatComposerQueueState,
+ item: ChatQueuedMessage,
+ accountUsage?: ChatAccountQueueUsage
+): ChatQueueAdmissionFailureStatus | null {
+ if (!chatQueuedMessageHasContent(item)) return "empty";
+ if (chatQueuedTextByteLength(item.text) > MAX_CHAT_QUEUED_TEXT_BYTES) {
+ return "text_too_large";
+ }
+ const images = queuedMessageImages(item);
+ if (images.length > MAX_CHAT_QUEUED_IMAGES_PER_ITEM) {
+ return "too_many_images";
+ }
+ if (images.some((image) => image.size > MAX_CHAT_QUEUED_IMAGE_BYTES)) {
+ return "image_too_large";
+ }
+ if (chatQueuedTextByteLength(item.documentText) > MAX_CHAT_QUEUED_DOCUMENT_BYTES) {
+ return "document_too_large";
+ }
+ if (queue.items.length >= MAX_CHAT_QUEUED_MESSAGES) return "queue_full";
+ if (
+ queuedAttachmentByteLength(queue.items) + chatQueuedMessageAttachmentByteLength(item) >
+ MAX_CHAT_QUEUED_AGGREGATE_ATTACHMENT_BYTES
+ ) {
+ return "queue_payload_too_large";
+ }
+ if (accountUsage && accountUsage.queuedMessageCount >= MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES) {
+ return "account_queue_full";
+ }
+ if (accountUsage && accountUsage.attachmentBytes > MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES) {
+ return "account_payload_too_large";
+ }
+ return null;
+}
+
+export function enqueueChatQueuedMessage(
+ queue: ChatComposerQueueState,
+ item: ChatQueuedMessage,
+ accountUsage?: ChatAccountQueueUsage
+): EnqueueChatQueuedMessageResult {
+ const rejected = validateQueuedMessagePayload(queue, item, accountUsage);
+ if (rejected) return { status: rejected, queue };
+
+ return {
+ status: "enqueued",
+ queue: { ...queue, items: [...queue.items, item] }
+ };
+}
+
+export function cancelChatQueuedMessage(
+ queue: ChatComposerQueueState,
+ queueId: string
+): CancelChatQueuedMessageResult {
+ const index = queue.items.findIndex((item) => item.queueId === queueId);
+ if (index < 0) return { status: "missing", queue };
+
+ const item = queue.items[index];
+ const editingThisItem = queue.edit?.queueId === queueId;
+ const items = [...queue.items];
+ items.splice(index, 1);
+ return {
+ status: "cancelled",
+ queue: {
+ items,
+ edit: editingThisItem ? null : queue.edit
+ },
+ item,
+ restoreInput:
+ editingThisItem && queue.edit ? discardSharedQueuedMessageEdit(queue.edit) : undefined
+ };
+}
+
+export function beginChatQueuedMessageEdit(
+ queue: ChatComposerQueueState,
+ scopeKey: string,
+ queueId: string,
+ composerInput: string
+): BeginChatQueuedMessageEditResult {
+ const item = queue.items.find((queued) => queued.queueId === queueId);
+ if (!item) return { status: "missing", queue };
+ // Queue state is runtime-local, so a matching queue ID is the same edit even
+ // if draft-to-conversation rekeying changed the runtime scope. Normalize an
+ // older real runtime key before switching items so the shared stash logic
+ // keeps the pre-edit draft instead of treating it as a different runtime.
+ if (queue.edit?.queueId === queueId) return { status: "already_editing", queue };
+ const currentEdit = queue.edit ? { ...queue.edit, scopeKey } : null;
+ const result = beginSharedQueuedMessageEdit({
+ current: currentEdit,
+ scopeKey,
+ item,
+ composerText: composerInput
+ });
+ if (!result) return { status: "already_editing", queue };
+
+ return {
+ status: "started",
+ queue: { ...queue, edit: result.edit },
+ input: result.composer
+ };
+}
+
+export function discardChatQueuedMessageEdit(
+ queue: ChatComposerQueueState
+): EndChatQueuedMessageEditResult {
+ if (!queue.edit) return { status: "not_editing", queue };
+ return {
+ status: "ended",
+ queue: { ...queue, edit: null },
+ restoreInput: discardSharedQueuedMessageEdit(queue.edit)
+ };
+}
+
+export function updateChatQueuedMessage(
+ queue: ChatComposerQueueState,
+ queueId: string,
+ text: string
+): UpdateChatQueuedMessageResult {
+ const index = queue.items.findIndex((item) => item.queueId === queueId);
+ if (index < 0) return { status: "missing", queue };
+
+ const trimmedText = text.trim();
+ const current = queue.items[index];
+ if (!trimmedText && current.draftImages.length === 0 && !current.documentText) {
+ return { status: "empty", queue };
+ }
+ if (chatQueuedTextByteLength(trimmedText) > MAX_CHAT_QUEUED_TEXT_BYTES) {
+ return { status: "text_too_large", queue };
+ }
+
+ const item = { ...current, text: trimmedText };
+ const items = [...queue.items];
+ items[index] = item;
+ const editingThisItem = queue.edit?.queueId === queueId;
+ const restoreInput =
+ editingThisItem && queue.edit ? discardSharedQueuedMessageEdit(queue.edit) : undefined;
+ return {
+ status: "updated",
+ queue: { items, edit: editingThisItem ? null : queue.edit },
+ item,
+ restoreInput
+ };
+}
+
+export function queuedChatMessageEditStillPresent(queue: ChatComposerQueueState): boolean {
+ return sharedQueuedMessageEditStillPresent(queue.edit, queue.items);
+}
+
+export function takeNextChatQueuedMessage(
+ queue: ChatComposerQueueState
+): TakeNextChatQueuedMessageResult {
+ if (queue.edit) return { status: "blocked_by_edit", queue };
+ const item = queue.items[0];
+ if (!item) return { status: "empty", queue };
+ return {
+ status: "taken",
+ queue: { items: queue.items.slice(1), edit: null },
+ item
+ };
+}
+
+export function detachChatComposerDraft(
+ composer: TComposer,
+ metadata: ChatQueuedMessageMetadata
+): Readonly<{ composer: TComposer; item: ChatQueuedMessage }> {
+ const item: ChatQueuedMessage = {
+ ...metadata,
+ text: composer.input.trim(),
+ draftImages: [...composer.draftImages],
+ imageUrls: new Map(composer.imageUrls),
+ documentText: composer.documentText,
+ documentName: composer.documentName,
+ draftProjectId: composer.draftProjectId
+ };
+ const nextComposer = {
+ ...composer,
+ input: "",
+ draftImages: [],
+ imageUrls: new Map(),
+ documentText: "",
+ documentName: "",
+ isProcessingDocument: false,
+ imagePasteGeneration: composer.imagePasteGeneration + 1,
+ documentUploadGeneration: composer.documentUploadGeneration + 1
+ };
+
+ return { composer: nextComposer, item };
+}
+
+export function stageChatComposerDraft(
+ composer: TComposer,
+ metadata: ChatQueuedMessageMetadata,
+ accountUsage?: ChatAccountQueueUsage
+): StageChatComposerDraftResult {
+ const detached = detachChatComposerDraft(composer, metadata);
+ const enqueued = enqueueChatQueuedMessage(composer.queue, detached.item, accountUsage);
+ if (enqueued.status !== "enqueued") {
+ return { status: enqueued.status, composer };
+ }
+
+ return {
+ status: "enqueued",
+ composer: { ...detached.composer, queue: enqueued.queue },
+ item: detached.item
+ };
+}
+
+export function recoverDetachedChatComposerDraft(
+ composer: TComposer,
+ item: ChatQueuedMessage
+): RecoverDetachedChatComposerDraftResult {
+ if (
+ composer.queue.items.some(
+ (queued) => queued.queueId === item.queueId || queued.messageId === item.messageId
+ )
+ ) {
+ return { status: "already_queued", composer };
+ }
+
+ if (
+ !chatComposerHasDraftMaterial(composer) &&
+ composer.queue.items.length === 0 &&
+ composer.queue.edit === null
+ ) {
+ return {
+ status: "restored",
+ composer: {
+ ...composer,
+ input: item.text,
+ draftImages: [...item.draftImages],
+ imageUrls: new Map(item.imageUrls),
+ documentText: item.documentText,
+ documentName: item.documentName,
+ draftProjectId: item.draftProjectId,
+ isProcessingDocument: false,
+ imagePasteGeneration: composer.imagePasteGeneration + 1,
+ documentUploadGeneration: composer.documentUploadGeneration + 1
+ }
+ };
+ }
+
+ return {
+ status: "requeued",
+ composer: {
+ ...composer,
+ queue: { ...composer.queue, items: [item, ...composer.queue.items] }
+ }
+ };
+}
+
+function combineDraftText(destination: string, source: string, separator: string): string {
+ if (!destination) return source;
+ if (!source) return destination;
+ return `${destination}${separator}${source}`;
+}
+
+type PreparedComposerEdit = Readonly<{
+ items: ChatQueuedMessage[];
+ draftInput: string;
+ visibleEdit: Readonly<{ edit: ChatQueuedMessageEdit; input: string }> | null;
+}>;
+
+function preserveComposerQueueEdit(composer: ChatComposerDraft): PreparedComposerEdit {
+ const edit = composer.queue.edit;
+ if (!edit) {
+ return { items: composer.queue.items, draftInput: composer.input, visibleEdit: null };
+ }
+
+ if (!composer.queue.items.some((item) => item.queueId === edit.queueId)) {
+ return {
+ items: composer.queue.items,
+ draftInput: combineDraftText(discardSharedQueuedMessageEdit(edit), composer.input, "\n"),
+ visibleEdit: null
+ };
+ }
+
+ return {
+ items: composer.queue.items,
+ draftInput: discardSharedQueuedMessageEdit(edit),
+ visibleEdit: { edit, input: composer.input }
+ };
+}
+
+function foldComposerQueueEditIntoDraft(composer: ChatComposerDraft): PreparedComposerEdit {
+ const prepared = preserveComposerQueueEdit(composer);
+ if (!prepared.visibleEdit) return prepared;
+
+ // A concurrent destination edit owns the one visible editor. Never turn the
+ // source's partially edited text into a promotable queued item without an
+ // explicit submit: retain the original item and move both source text fields
+ // into the surviving edit's non-promotable stash.
+ return {
+ items: composer.queue.items,
+ draftInput: combineDraftText(
+ discardSharedQueuedMessageEdit(prepared.visibleEdit.edit),
+ prepared.visibleEdit.input,
+ "\n"
+ ),
+ visibleEdit: null
+ };
+}
+
+type MergedQueueItems = Readonly<{
+ items: ChatQueuedMessage[];
+ destinationEditQueueId: string | null;
+ sourceEditQueueId: string | null;
+}>;
+
+function mergeQueueItems(
+ destinationItems: ChatQueuedMessage[],
+ sourceItems: ChatQueuedMessage[],
+ destinationEditQueueId: string | null,
+ sourceEditQueueId: string | null
+): MergedQueueItems {
+ const usedQueueIds = new Set();
+ const usedMessageIds = new Set();
+ const makeUniqueId = (candidate: string, used: Set): string => {
+ if (!used.has(candidate)) {
+ used.add(candidate);
+ return candidate;
+ }
+ let suffix = 2;
+ while (used.has(`${candidate}:rekey:${suffix}`)) suffix += 1;
+ const unique = `${candidate}:rekey:${suffix}`;
+ used.add(unique);
+ return unique;
+ };
+ let normalizedDestinationEditQueueId: string | null = null;
+ let normalizedSourceEditQueueId: string | null = null;
+ const normalizeIds = (
+ item: ChatQueuedMessage,
+ origin: "destination" | "source"
+ ): ChatQueuedMessage => {
+ const originalQueueId = item.queueId;
+ const queueId = makeUniqueId(item.queueId, usedQueueIds);
+ const messageId = makeUniqueId(item.messageId, usedMessageIds);
+ if (
+ origin === "destination" &&
+ normalizedDestinationEditQueueId === null &&
+ originalQueueId === destinationEditQueueId
+ ) {
+ normalizedDestinationEditQueueId = queueId;
+ }
+ if (
+ origin === "source" &&
+ normalizedSourceEditQueueId === null &&
+ originalQueueId === sourceEditQueueId
+ ) {
+ normalizedSourceEditQueueId = queueId;
+ }
+ return queueId === item.queueId && messageId === item.messageId
+ ? item
+ : { ...item, queueId, messageId };
+ };
+ const combined = [
+ ...destinationItems.map((item, index) => ({
+ item: normalizeIds(item, "destination"),
+ origin: 0,
+ index
+ })),
+ ...sourceItems.map((item, index) => ({
+ item: normalizeIds(item, "source"),
+ origin: 1,
+ index
+ }))
+ ];
+ return {
+ items: combined
+ .sort(
+ (left, right) =>
+ left.item.createdMs - right.item.createdMs ||
+ left.origin - right.origin ||
+ left.index - right.index
+ )
+ .map(({ item }) => item),
+ destinationEditQueueId: normalizedDestinationEditQueueId,
+ sourceEditQueueId: normalizedSourceEditQueueId
+ };
+}
+
+function mergeDraftImages(
+ destinationImages: File[],
+ destinationUrls: Map,
+ sourceImages: File[],
+ sourceUrls: Map
+): Readonly<{ images: File[]; urls: Map; displacedObjectUrls: string[] }> {
+ const images = [...destinationImages];
+ const knownImages = new Set(images);
+ for (const image of sourceImages) {
+ if (!knownImages.has(image)) {
+ knownImages.add(image);
+ images.push(image);
+ }
+ }
+
+ const urls = new Map(sourceUrls);
+ const displacedObjectUrls: string[] = [];
+ for (const [file, destinationUrl] of destinationUrls) {
+ const sourceUrl = urls.get(file);
+ if (sourceUrl && sourceUrl !== destinationUrl) displacedObjectUrls.push(sourceUrl);
+ urls.set(file, destinationUrl);
+ }
+ return { images, urls, displacedObjectUrls };
+}
+
+/**
+ * Reconciles a draft runtime with an independently materialized conversation.
+ * The destination edit remains visible when both runtimes are editing. A lone
+ * source edit remains open. When both edit, the source's original item stays
+ * unchanged and both source text fields move into the destination edit's stash,
+ * keeping the entire FIFO blocked until the user explicitly resolves that edit.
+ */
+export function mergeChatComposerDraftsForRekey(
+ source: TComposer,
+ destination: TComposer,
+ targetScopeKey: string
+): MergeChatComposerDraftsResult {
+ const preparedDestination = preserveComposerQueueEdit(destination);
+ const preparedSource = preparedDestination.visibleEdit
+ ? foldComposerQueueEditIntoDraft(source)
+ : preserveComposerQueueEdit(source);
+ const imageMerge = mergeDraftImages(
+ destination.draftImages,
+ destination.imageUrls,
+ source.draftImages,
+ source.imageUrls
+ );
+ const queueMerge = mergeQueueItems(
+ preparedDestination.items,
+ preparedSource.items,
+ preparedDestination.visibleEdit?.edit.queueId ?? null,
+ preparedSource.visibleEdit?.edit.queueId ?? null
+ );
+ const visibleEdit = preparedDestination.visibleEdit ?? preparedSource.visibleEdit;
+ const visibleEditQueueId = preparedDestination.visibleEdit
+ ? queueMerge.destinationEditQueueId
+ : queueMerge.sourceEditQueueId;
+ const mergedDraftInput = combineDraftText(
+ preparedDestination.draftInput,
+ preparedSource.draftInput,
+ "\n"
+ );
+ const edit =
+ visibleEdit && visibleEditQueueId
+ ? {
+ ...visibleEdit.edit,
+ scopeKey: targetScopeKey,
+ queueId: visibleEditQueueId,
+ stashedDraft: mergedDraftInput
+ }
+ : null;
+ const input = edit && visibleEdit ? visibleEdit.input : mergedDraftInput;
+
+ const composer = {
+ ...source,
+ ...destination,
+ input,
+ draftImages: imageMerge.images,
+ imageUrls: imageMerge.urls,
+ documentText: combineDraftText(destination.documentText, source.documentText, "\n\n"),
+ documentName: combineDraftText(destination.documentName, source.documentName, ", "),
+ isProcessingDocument: destination.isProcessingDocument || source.isProcessingDocument,
+ imagePasteGeneration: Math.max(destination.imagePasteGeneration, source.imagePasteGeneration),
+ documentUploadGeneration: Math.max(
+ destination.documentUploadGeneration,
+ source.documentUploadGeneration
+ ),
+ queue: { items: queueMerge.items, edit }
+ } as TComposer;
+ const retainedObjectUrls = new Set(chatComposerObjectUrls(composer));
+
+ return {
+ composer,
+ displacedObjectUrls: Array.from(new Set(imageMerge.displacedObjectUrls)).filter(
+ (url) => !retainedObjectUrls.has(url)
+ )
+ };
+}
+
+export function chatComposerObjectUrls(composer: ChatComposerDraft): string[] {
+ const urls = new Set(composer.imageUrls.values());
+ for (const item of composer.queue.items) {
+ for (const url of item.imageUrls.values()) urls.add(url);
+ }
+ return Array.from(urls);
+}
+
+export function disposeChatComposerObjectUrls(
+ composer: ChatComposerDraft,
+ revokeObjectUrl: (url: string) => void = URL.revokeObjectURL
+): void {
+ for (const url of chatComposerObjectUrls(composer)) revokeObjectUrl(url);
+}
diff --git a/frontend/src/services/chatComposerSend.test.ts b/frontend/src/services/chatComposerSend.test.ts
new file mode 100644
index 000000000..7b3d31e97
--- /dev/null
+++ b/frontend/src/services/chatComposerSend.test.ts
@@ -0,0 +1,303 @@
+import { describe, expect, test } from "bun:test";
+import { createChatComposerState } from "@/contexts/ChatRuntimeContext";
+import {
+ emptyChatComposerQueueState,
+ MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES,
+ MAX_CHAT_QUEUED_MESSAGES,
+ type ChatQueuedMessage
+} from "./chatComposerQueue";
+import {
+ canSubmitChatComposer,
+ chatComposerWithInputOverride,
+ chatComposerShowsStop,
+ planChatComposerSubmission
+} from "./chatComposerSend";
+
+const metadata = {
+ queueId: "queue-new",
+ messageId: "message-new",
+ model: "test-model",
+ webSearchEnabled: false,
+ createdMs: 3
+};
+
+function queued(queueId: string, createdMs: number, text = queueId): ChatQueuedMessage {
+ return {
+ queueId,
+ messageId: `message-${queueId}`,
+ text,
+ draftImages: [],
+ imageUrls: new Map(),
+ documentText: "",
+ documentName: "",
+ draftProjectId: null,
+ model: "test-model",
+ webSearchEnabled: false,
+ createdMs
+ };
+}
+
+describe("chat composer submission planning", () => {
+ test("retains a voice input override without disturbing queue or attachments", () => {
+ const image = new File(["image"], "image.png", { type: "image/png" });
+ const composer = {
+ ...createChatComposerState(),
+ input: "before",
+ draftImages: [image],
+ queue: {
+ items: [queued("one", 1)],
+ edit: { scopeKey: "conversation:1", queueId: "one", stashedDraft: "later" }
+ }
+ };
+
+ const retained = chatComposerWithInputOverride(composer, "before dictated words");
+
+ expect(retained.input).toBe("before dictated words");
+ expect(retained.draftImages).toBe(composer.draftImages);
+ expect(retained.queue).toBe(composer.queue);
+ expect(chatComposerWithInputOverride(retained, undefined)).toBe(retained);
+ });
+
+ test("detaches an idle draft before asynchronous send work", () => {
+ const composer = { ...createChatComposerState(), input: " first message " };
+ const plan = planChatComposerSubmission({ composer, hasActiveRun: false, metadata });
+
+ expect(plan.status).toBe("start");
+ if (plan.status !== "start") return;
+ expect(plan.item.text).toBe("first message");
+ expect(plan.composer.input).toBe("");
+ expect(plan.recoverOnFailure).toBe(true);
+ });
+
+ test("stages a mid-run draft without starting a second run", () => {
+ const composer = { ...createChatComposerState(), input: "follow up" };
+ const plan = planChatComposerSubmission({ composer, hasActiveRun: true, metadata });
+
+ expect(plan.status).toBe("queued");
+ if (plan.status !== "queued") return;
+ expect(plan.composer.input).toBe("");
+ expect(plan.composer.queue.items.map((item) => item.text)).toEqual(["follow up"]);
+ });
+
+ test("appends the live draft after leftovers and starts the oldest item", () => {
+ const composer = {
+ ...createChatComposerState(),
+ input: "newest",
+ queue: { items: [queued("oldest", 1), queued("middle", 2)], edit: null }
+ };
+ const plan = planChatComposerSubmission({ composer, hasActiveRun: false, metadata });
+
+ expect(plan.status).toBe("start");
+ if (plan.status !== "start") return;
+ expect(plan.item.queueId).toBe("oldest");
+ expect(plan.composer.queue.items.map((item) => item.queueId)).toEqual(["middle", "queue-new"]);
+ expect(plan.recoverOnFailure).toBe(false);
+ });
+
+ test("resumes a full queue before appending the live draft at the tail", () => {
+ const items = Array.from({ length: MAX_CHAT_QUEUED_MESSAGES }, (_, index) =>
+ queued(`queued-${index}`, index + 1)
+ );
+ const composer = {
+ ...createChatComposerState(),
+ input: "newest",
+ queue: { items, edit: null }
+ };
+ const plan = planChatComposerSubmission({ composer, hasActiveRun: false, metadata });
+
+ expect(plan.status).toBe("start");
+ if (plan.status !== "start") return;
+ expect(plan.item.queueId).toBe("queued-0");
+ expect(plan.composer.queue.items).toHaveLength(MAX_CHAT_QUEUED_MESSAGES);
+ expect(plan.composer.queue.items.at(-1)?.queueId).toBe("queue-new");
+ });
+
+ test("starts the retained FIFO but leaves the live draft when the account queue is full", () => {
+ const composer = {
+ ...createChatComposerState(),
+ input: "keep this live",
+ queue: { items: [queued("oldest", 1)], edit: null }
+ };
+ const plan = planChatComposerSubmission({
+ composer,
+ hasActiveRun: false,
+ metadata,
+ accountUsage: {
+ queuedMessageCount: MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES,
+ attachmentBytes: 0,
+ imageCount: 0
+ }
+ });
+
+ expect(plan.status).toBe("start");
+ if (plan.status !== "start") return;
+ expect(plan.item.queueId).toBe("oldest");
+ expect(plan.composer.input).toBe("keep this live");
+ expect(plan.composer.queue.items).toEqual([]);
+ });
+
+ test("reserves a recovery slot before starting a direct idle draft", () => {
+ const composer = { ...createChatComposerState(), input: "keep this live" };
+ const plan = planChatComposerSubmission({
+ composer,
+ hasActiveRun: false,
+ metadata,
+ accountUsage: {
+ queuedMessageCount: MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES,
+ attachmentBytes: 0,
+ imageCount: 0
+ }
+ });
+
+ expect(plan.status).toBe("account_queue_full");
+ expect(composer.input).toBe("keep this live");
+ });
+
+ test("drains a losslessly recovered queue that is already over the local limit", () => {
+ const items = Array.from({ length: MAX_CHAT_QUEUED_MESSAGES + 2 }, (_, index) =>
+ queued(`recovered-${index}`, index + 1)
+ );
+ const composer = {
+ ...createChatComposerState(),
+ input: "keep this editable",
+ queue: { items, edit: null }
+ };
+ const plan = planChatComposerSubmission({ composer, hasActiveRun: false, metadata });
+
+ expect(plan.status).toBe("start");
+ if (plan.status !== "start") return;
+ expect(plan.item.queueId).toBe("recovered-0");
+ expect(plan.composer.input).toBe("keep this editable");
+ expect(plan.composer.queue.items).toHaveLength(MAX_CHAT_QUEUED_MESSAGES + 1);
+ });
+
+ test("rejects a new mid-run item when the account queue is full", () => {
+ const composer = { ...createChatComposerState(), input: "not lost" };
+ const plan = planChatComposerSubmission({
+ composer,
+ hasActiveRun: true,
+ metadata,
+ accountUsage: {
+ queuedMessageCount: MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES,
+ attachmentBytes: 0,
+ imageCount: 0
+ }
+ });
+
+ expect(plan.status).toBe("account_queue_full");
+ expect(composer.input).toBe("not lost");
+ });
+
+ test("saves an edit in place and either holds or resumes the FIFO", () => {
+ const base = {
+ ...createChatComposerState(),
+ input: "edited",
+ queue: {
+ items: [queued("one", 1), queued("two", 2)],
+ edit: { scopeKey: "conversation:1", queueId: "two", stashedDraft: "later draft" }
+ }
+ };
+
+ const held = planChatComposerSubmission({ composer: base, hasActiveRun: true, metadata });
+ expect(held.status).toBe("updated");
+ if (held.status !== "updated") return;
+ expect(held.composer.input).toBe("later draft");
+ expect(held.composer.queue.items[1].text).toBe("edited");
+
+ const resumed = planChatComposerSubmission({ composer: base, hasActiveRun: false, metadata });
+ expect(resumed.status).toBe("start");
+ if (resumed.status !== "start") return;
+ expect(resumed.item.queueId).toBe("one");
+ expect(resumed.composer.queue.items[0].text).toBe("edited");
+ });
+
+ test("empty idle send flushes a retained queue but empty active send does nothing", () => {
+ const composer = {
+ ...createChatComposerState(),
+ queue: { items: [queued("one", 1)], edit: null }
+ };
+ expect(planChatComposerSubmission({ composer, hasActiveRun: true, metadata }).status).toBe(
+ "empty"
+ );
+ expect(planChatComposerSubmission({ composer, hasActiveRun: false, metadata }).status).toBe(
+ "start"
+ );
+ });
+
+ test("send and Stop policy keeps both controls available during a run", () => {
+ expect(
+ canSubmitChatComposer({
+ text: "queue this",
+ hasAttachments: false,
+ hasQueuedMessages: true,
+ hasActiveRun: true,
+ isProcessingDocument: false,
+ isStopping: false
+ })
+ ).toBe(true);
+ expect(
+ canSubmitChatComposer({
+ text: "",
+ hasAttachments: false,
+ hasQueuedMessages: true,
+ hasActiveRun: true,
+ isProcessingDocument: false,
+ isStopping: false
+ })
+ ).toBe(false);
+ expect(chatComposerShowsStop(true, false)).toBe(true);
+ expect(chatComposerShowsStop(false, true)).toBe(true);
+ });
+
+ test("processing and stopping are hard submission fences", () => {
+ expect(
+ canSubmitChatComposer({
+ text: "message",
+ hasAttachments: false,
+ hasQueuedMessages: false,
+ hasActiveRun: false,
+ isProcessingDocument: true,
+ isStopping: false
+ })
+ ).toBe(false);
+ expect(
+ canSubmitChatComposer({
+ text: "message",
+ hasAttachments: false,
+ hasQueuedMessages: false,
+ hasActiveRun: false,
+ isProcessingDocument: false,
+ isStopping: true
+ })
+ ).toBe(false);
+ });
+
+ test("a blank text-only edit is disabled while an attachment-backed edit can submit", () => {
+ expect(
+ canSubmitChatComposer({
+ text: "",
+ hasAttachments: false,
+ hasQueuedMessages: true,
+ isEditingQueuedMessage: true,
+ hasActiveRun: false,
+ isProcessingDocument: false,
+ isStopping: false
+ })
+ ).toBe(false);
+ expect(
+ canSubmitChatComposer({
+ text: "",
+ hasAttachments: true,
+ hasQueuedMessages: true,
+ isEditingQueuedMessage: true,
+ hasActiveRun: false,
+ isProcessingDocument: false,
+ isStopping: false
+ })
+ ).toBe(true);
+ });
+
+ test("does not invent queue state while constructing a fixture", () => {
+ expect(emptyChatComposerQueueState()).toEqual({ items: [], edit: null });
+ });
+});
diff --git a/frontend/src/services/chatComposerSend.ts b/frontend/src/services/chatComposerSend.ts
new file mode 100644
index 000000000..6647e543f
--- /dev/null
+++ b/frontend/src/services/chatComposerSend.ts
@@ -0,0 +1,173 @@
+import {
+ chatQueuedMessageHasContent,
+ detachChatComposerDraft,
+ stageChatComposerDraft,
+ takeNextChatQueuedMessage,
+ updateChatQueuedMessage,
+ type ChatComposerDraft,
+ type ChatAccountQueueUsage,
+ type ChatQueueAdmissionFailureStatus,
+ type ChatQueuedMessage,
+ type ChatQueuedMessageMetadata,
+ MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES
+} from "./chatComposerQueue";
+
+export type ChatComposerSubmissionPlan =
+ | Readonly<{
+ status: ChatQueueAdmissionFailureStatus | "processing" | "missing_edit";
+ }>
+ | Readonly<{ status: "queued" | "updated"; composer: TComposer }>
+ | Readonly<{
+ status: "start";
+ composer: TComposer;
+ item: ChatQueuedMessage;
+ recoverOnFailure: boolean;
+ }>;
+
+/**
+ * Applies an externally produced composer value, such as a completed voice
+ * transcription, without disturbing the draft's queue or attachment state.
+ * Callers can also use this before a send fence returns so the produced text
+ * remains editable instead of being dropped.
+ */
+export function chatComposerWithInputOverride(
+ composer: TComposer,
+ overrideInput: string | undefined
+): TComposer {
+ if (overrideInput === undefined || overrideInput === composer.input) return composer;
+ return { ...composer, input: overrideInput };
+}
+
+/**
+ * Plans a submit as one synchronous composer mutation. In particular, a live
+ * draft is detached before image conversion or any network work can yield, so
+ * typing the next message can never be cleared by the previous send.
+ */
+export function planChatComposerSubmission({
+ composer,
+ hasActiveRun,
+ metadata,
+ accountUsage
+}: {
+ composer: TComposer;
+ hasActiveRun: boolean;
+ metadata: ChatQueuedMessageMetadata;
+ accountUsage?: ChatAccountQueueUsage;
+}): ChatComposerSubmissionPlan {
+ if (composer.isProcessingDocument) return { status: "processing" };
+
+ if (composer.queue.edit) {
+ const updated = updateChatQueuedMessage(
+ composer.queue,
+ composer.queue.edit.queueId,
+ composer.input
+ );
+ if (updated.status === "text_too_large") return { status: "text_too_large" };
+ if (updated.status === "empty") return { status: "empty" };
+ if (updated.status === "missing") return { status: "missing_edit" };
+ if (updated.status !== "updated") return { status: "missing_edit" };
+
+ const updatedComposer = {
+ ...composer,
+ input: updated.restoreInput ?? composer.input,
+ queue: updated.queue
+ };
+ if (hasActiveRun) return { status: "updated", composer: updatedComposer };
+
+ const next = takeNextChatQueuedMessage(updatedComposer.queue);
+ if (next.status !== "taken") return { status: "missing_edit" };
+ return {
+ status: "start",
+ composer: { ...updatedComposer, queue: next.queue },
+ item: next.item,
+ recoverOnFailure: false
+ };
+ }
+
+ const detached = detachChatComposerDraft(composer, metadata);
+ const liveDraftHasContent = chatQueuedMessageHasContent(detached.item);
+
+ if (hasActiveRun) {
+ if (!liveDraftHasContent) return { status: "empty" };
+ const staged = stageChatComposerDraft(composer, metadata, accountUsage);
+ if (staged.status !== "enqueued") return { status: staged.status };
+ return { status: "queued", composer: staged.composer };
+ }
+
+ if (composer.queue.items.length === 0) {
+ if (!liveDraftHasContent) return { status: "empty" };
+ // Reserve one account-wide queue slot while this detached turn is in
+ // flight. If it fails before the request becomes authoritative, recovery
+ // may need to put it ahead of a draft the user typed meanwhile.
+ if (
+ accountUsage &&
+ accountUsage.queuedMessageCount >= MAX_CHAT_ACCOUNT_RETAINED_QUEUE_MESSAGES
+ ) {
+ return { status: "account_queue_full" };
+ }
+ return {
+ status: "start",
+ composer: detached.composer,
+ item: detached.item,
+ recoverOnFailure: true
+ };
+ }
+
+ const next = takeNextChatQueuedMessage(composer.queue);
+ if (next.status !== "taken") return { status: "empty" };
+
+ let preparedComposer = { ...composer, queue: next.queue };
+ if (liveDraftHasContent) {
+ // Free the oldest slot before appending the live draft. This keeps a full
+ // queue resumable without ever retaining more than the configured limit.
+ const staged = stageChatComposerDraft(preparedComposer, metadata, accountUsage);
+ if (
+ staged.status === "queue_full" ||
+ staged.status === "queue_payload_too_large" ||
+ staged.status === "account_queue_full" ||
+ staged.status === "account_payload_too_large"
+ ) {
+ // The oldest item is already retained by the active turn. Keep the live
+ // draft editable while an over-cap lossless recovery/rekey FIFO drains,
+ // or until the account-wide reservation is released.
+ } else if (staged.status !== "enqueued") {
+ return { status: staged.status };
+ } else {
+ preparedComposer = staged.composer;
+ }
+ }
+
+ return {
+ status: "start",
+ composer: preparedComposer,
+ item: next.item,
+ recoverOnFailure: false
+ };
+}
+
+export function canSubmitChatComposer({
+ text,
+ hasAttachments,
+ hasQueuedMessages,
+ isEditingQueuedMessage = false,
+ hasActiveRun,
+ isProcessingDocument,
+ isStopping
+}: {
+ text: string;
+ hasAttachments: boolean;
+ hasQueuedMessages: boolean;
+ isEditingQueuedMessage?: boolean;
+ hasActiveRun: boolean;
+ isProcessingDocument: boolean;
+ isStopping: boolean;
+}): boolean {
+ if (isProcessingDocument || isStopping) return false;
+ const hasLiveContent = Boolean(text.trim()) || hasAttachments;
+ if (isEditingQueuedMessage) return hasLiveContent;
+ return hasLiveContent || (hasQueuedMessages && !hasActiveRun);
+}
+
+export function chatComposerShowsStop(isGenerating: boolean, isStopping: boolean): boolean {
+ return isGenerating || isStopping;
+}
diff --git a/frontend/src/services/chatCurrentTurnRegistry.test.ts b/frontend/src/services/chatCurrentTurnRegistry.test.ts
new file mode 100644
index 000000000..0493d78a9
--- /dev/null
+++ b/frontend/src/services/chatCurrentTurnRegistry.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, test } from "bun:test";
+import {
+ getRegisteredChatCurrentTurnPayloads,
+ registeredChatTurnCanSettleLocallyForDeletion,
+ registerChatCurrentTurn,
+ restoreRegisteredChatTurnBeforeRequest
+} from "./chatCurrentTurnRegistry";
+
+describe("chat current turn registry", () => {
+ test("restores only a turn that has not started its Responses request", () => {
+ const store = {};
+ let requestStarted = false;
+ const restored: string[] = [];
+ const unregister = registerChatCurrentTurn(store, 7, {
+ responseRequestStarted: () => requestStarted,
+ restoreBeforeRequest: (message) => {
+ restored.push(message);
+ return true;
+ }
+ });
+
+ expect(restoreRegisteredChatTurnBeforeRequest(store, 7, "stopped")).toBe(true);
+ expect(restored).toEqual(["stopped"]);
+
+ requestStarted = true;
+ expect(restoreRegisteredChatTurnBeforeRequest(store, 7, "too late")).toBe(false);
+ expect(restored).toEqual(["stopped"]);
+
+ unregister();
+ expect(restoreRegisteredChatTurnBeforeRequest(store, 7, "missing")).toBe(false);
+ });
+
+ test("an older unregister cannot remove a replacement control", () => {
+ const store = {};
+ const first = registerChatCurrentTurn(store, 9, {
+ responseRequestStarted: () => false,
+ restoreBeforeRequest: () => false
+ });
+ let restored = false;
+ registerChatCurrentTurn(store, 9, {
+ responseRequestStarted: () => false,
+ restoreBeforeRequest: () => (restored = true),
+ retainedPayload: { queueId: "replacement" },
+ countsTowardQueueLimit: true
+ });
+
+ first();
+ expect(restoreRegisteredChatTurnBeforeRequest(store, 9, "replacement")).toBe(true);
+ expect(restored).toBe(true);
+ expect(getRegisteredChatCurrentTurnPayloads(store)).toEqual([
+ { payload: { queueId: "replacement" }, countsTowardQueueLimit: true }
+ ]);
+ });
+
+ test("restores for Stop but does not authorize deletion settlement during conversation creation", () => {
+ const store = {};
+ let createInFlight = true;
+ const restored: string[] = [];
+ registerChatCurrentTurn(store, 10, {
+ responseRequestStarted: () => false,
+ serverRequestInFlight: () => createInFlight,
+ restoreBeforeRequest: (message) => {
+ restored.push(message);
+ return true;
+ }
+ });
+
+ expect(restoreRegisteredChatTurnBeforeRequest(store, 10, "stop now")).toBe(true);
+ expect(registeredChatTurnCanSettleLocallyForDeletion(store, 10)).toBe(false);
+ createInFlight = false;
+ expect(restoreRegisteredChatTurnBeforeRequest(store, 10, "safe now")).toBe(true);
+ expect(registeredChatTurnCanSettleLocallyForDeletion(store, 10)).toBe(true);
+ expect(restored).toEqual(["stop now", "safe now"]);
+ });
+
+ test("stops retaining a payload as soon as ownership transfers back to a composer", () => {
+ const store = {};
+ let retainsPayload = true;
+ registerChatCurrentTurn(store, 11, {
+ responseRequestStarted: () => false,
+ restoreBeforeRequest: () => false,
+ retainedPayload: { queueId: "current" },
+ retainsPayload: () => retainsPayload,
+ countsTowardQueueLimit: true
+ });
+
+ expect(getRegisteredChatCurrentTurnPayloads(store)).toHaveLength(1);
+ retainsPayload = false;
+ expect(getRegisteredChatCurrentTurnPayloads(store)).toEqual([]);
+ });
+});
diff --git a/frontend/src/services/chatCurrentTurnRegistry.ts b/frontend/src/services/chatCurrentTurnRegistry.ts
new file mode 100644
index 000000000..e466cc788
--- /dev/null
+++ b/frontend/src/services/chatCurrentTurnRegistry.ts
@@ -0,0 +1,81 @@
+type ChatCurrentTurnControl = Readonly<{
+ responseRequestStarted: () => boolean;
+ serverRequestInFlight?: () => boolean;
+ restoreBeforeRequest: (message: string) => boolean;
+ retainedPayload?: unknown;
+ retainsPayload?: () => boolean;
+ countsTowardQueueLimit?: boolean;
+}>;
+
+export type RegisteredChatCurrentTurnPayload = Readonly<{
+ payload: unknown;
+ countsTowardQueueLimit: boolean;
+}>;
+
+const currentTurnsByStore = new WeakMap>();
+
+function controlsFor(store: object): Map {
+ const existing = currentTurnsByStore.get(store);
+ if (existing) return existing;
+ const created = new Map();
+ currentTurnsByStore.set(store, created);
+ return created;
+}
+
+export function registerChatCurrentTurn(
+ store: object,
+ runToken: number,
+ control: ChatCurrentTurnControl
+): () => void {
+ const controls = controlsFor(store);
+ controls.set(runToken, control);
+ return () => {
+ if (controls.get(runToken) === control) controls.delete(runToken);
+ };
+}
+
+/**
+ * Restores a detached turn only while it is still entirely client-side. Once a
+ * Responses request starts, replaying it would risk a duplicate assistant turn.
+ */
+export function restoreRegisteredChatTurnBeforeRequest(
+ store: object,
+ runToken: number,
+ message: string
+): boolean {
+ const control = currentTurnsByStore.get(store)?.get(runToken);
+ if (!control || control.responseRequestStarted()) return false;
+ return control.restoreBeforeRequest(message);
+}
+
+/**
+ * Deletion may only retire the run locally when no server mutation is still
+ * capable of committing. Stop can abort a conversation-create request, but a
+ * destructive caller must keep waiting until that request settles.
+ */
+export function registeredChatTurnCanSettleLocallyForDeletion(
+ store: object,
+ runToken: number
+): boolean {
+ const control = currentTurnsByStore.get(store)?.get(runToken);
+ return Boolean(
+ control && !control.responseRequestStarted() && !control.serverRequestInFlight?.()
+ );
+}
+
+export function getRegisteredChatCurrentTurnPayloads(
+ store: object
+): readonly RegisteredChatCurrentTurnPayload[] {
+ const controls = currentTurnsByStore.get(store);
+ if (!controls) return [];
+ return Array.from(controls.values()).flatMap((control) =>
+ control.retainedPayload === undefined || control.retainsPayload?.() === false
+ ? []
+ : [
+ {
+ payload: control.retainedPayload,
+ countsTowardQueueLimit: control.countsTowardQueueLimit ?? false
+ }
+ ]
+ );
+}
diff --git a/frontend/src/services/chatPollingPage.test.ts b/frontend/src/services/chatPollingPage.test.ts
new file mode 100644
index 000000000..cf4fdc2b3
--- /dev/null
+++ b/frontend/src/services/chatPollingPage.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from "bun:test";
+import { normalizeChatPollingPage } from "./chatPollingPage";
+
+const oldest = { id: "oldest", status: "completed" };
+const middle = { id: "middle", status: "completed" };
+const newest = { id: "newest", status: "in_progress" };
+
+describe("normalizeChatPollingPage", () => {
+ test("keeps cursor-based ascending pages chronological", () => {
+ const normalized = normalizeChatPollingPage([oldest, middle, newest], true);
+
+ expect(normalized.chronologicalItems.map((item) => item.id)).toEqual([
+ "oldest",
+ "middle",
+ "newest"
+ ]);
+ expect(normalized.newestCompletedItem?.id).toBe("middle");
+ });
+
+ test("reverses a no-cursor descending recovery page before merging", () => {
+ const normalized = normalizeChatPollingPage([newest, middle, oldest], false);
+
+ expect(normalized.chronologicalItems.map((item) => item.id)).toEqual([
+ "oldest",
+ "middle",
+ "newest"
+ ]);
+ expect(normalized.newestCompletedItem?.id).toBe("middle");
+ });
+});
diff --git a/frontend/src/services/chatPollingPage.ts b/frontend/src/services/chatPollingPage.ts
new file mode 100644
index 000000000..6ab39a306
--- /dev/null
+++ b/frontend/src/services/chatPollingPage.ts
@@ -0,0 +1,19 @@
+export type NormalizedChatPollingPage = Readonly<{
+ chronologicalItems: T[];
+ newestCompletedItem: T | undefined;
+}>;
+
+/**
+ * Cursor polls arrive ascending. A recovery poll without a durable cursor asks
+ * for the newest page descending, then reverses it before transcript merging.
+ */
+export function normalizeChatPollingPage(
+ items: readonly T[],
+ hasCursor: boolean
+): NormalizedChatPollingPage {
+ const newestFirst = hasCursor ? [...items].reverse() : [...items];
+ return {
+ chronologicalItems: hasCursor ? [...items] : [...items].reverse(),
+ newestCompletedItem: newestFirst.find((item) => item.status !== "in_progress")
+ };
+}
diff --git a/frontend/src/services/chatResponseErrors.test.ts b/frontend/src/services/chatResponseErrors.test.ts
index dbb480d2a..53213c784 100644
--- a/frontend/src/services/chatResponseErrors.test.ts
+++ b/frontend/src/services/chatResponseErrors.test.ts
@@ -1,8 +1,15 @@
import { describe, expect, test } from "bun:test";
import { APIConnectionError } from "openai";
-import { isImageDescriptionUnavailableError } from "./chatResponseErrors";
+import {
+ isChatRequestDefinitelyNotDispatchedError,
+ isChatResponseCancellationAlreadyTerminalError,
+ isChatResponseDefinitelyRejectedError,
+ isImageDescriptionUnavailableError
+} from "./chatResponseErrors";
-function codedError(
+const REQUEST_NOT_DISPATCHED_CODE = "opensecret_request_not_dispatched";
+
+function codedImageDescriptionError(
status = 503,
contract = "1",
code = "image_description_unavailable"
@@ -16,21 +23,78 @@ function codedError(
});
}
-describe("Responses error classification", () => {
+describe("chat response error ownership", () => {
+ test("recognizes a nested SDK pre-transport marker without replacing error identity", () => {
+ const root = Object.assign(new Error("attestation failed"), {
+ requestDispatchCode: REQUEST_NOT_DISPATCHED_CODE,
+ definitelyNotDispatched: true
+ });
+
+ expect(isChatRequestDefinitelyNotDispatchedError(new Error("wrapped", { cause: root }))).toBe(
+ true
+ );
+ });
+
+ test("recognizes ordinary application rejections", () => {
+ expect(isChatResponseDefinitelyRejectedError({ status: 400 })).toBe(true);
+ expect(isChatResponseDefinitelyRejectedError({ cause: { status: 422 } })).toBe(true);
+ expect(isChatResponseDefinitelyRejectedError({ status: 429 })).toBe(true);
+ expect(
+ isChatResponseDefinitelyRejectedError({
+ status: 503,
+ headers: new Headers({
+ "x-opensecret-error-contract": "1",
+ "x-opensecret-error-code": "image_description_unavailable"
+ })
+ })
+ ).toBe(true);
+ });
+
+ test("keeps transport failures, server failures, and request timeouts ambiguous", () => {
+ expect(isChatRequestDefinitelyNotDispatchedError(new TypeError("fetch failed"))).toBe(false);
+ expect(isChatResponseDefinitelyRejectedError({ status: 500 })).toBe(false);
+ expect(
+ isChatResponseDefinitelyRejectedError({
+ status: 500,
+ headers: new Headers({ "x-opensecret-error-contract": "1" })
+ })
+ ).toBe(false);
+ expect(
+ isChatResponseDefinitelyRejectedError({
+ status: 503,
+ headers: new Headers({ "x-opensecret-error-contract": "1" })
+ })
+ ).toBe(false);
+ expect(isChatResponseDefinitelyRejectedError({ status: 408 })).toBe(false);
+ });
+
+ test("only recognizes the cancel endpoint's already-terminal response", () => {
+ expect(isChatResponseCancellationAlreadyTerminalError({ status: 400 })).toBe(true);
+ expect(isChatResponseCancellationAlreadyTerminalError({ cause: { status: 400 } })).toBe(true);
+ expect(isChatResponseCancellationAlreadyTerminalError({ status: 503 })).toBe(false);
+ expect(isChatResponseCancellationAlreadyTerminalError(new TypeError("fetch failed"))).toBe(
+ false
+ );
+ });
+});
+
+describe("image-description error classification", () => {
test("recognizes the coded descriptor failure through the OpenAI connection wrapper", () => {
- const error = new APIConnectionError({ cause: codedError() });
+ const error = new APIConnectionError({ cause: codedImageDescriptionError() });
expect(isImageDescriptionUnavailableError(error)).toBe(true);
});
test("recognizes a top-level OpenAI-style HTTP error", () => {
- expect(isImageDescriptionUnavailableError(codedError())).toBe(true);
+ expect(isImageDescriptionUnavailableError(codedImageDescriptionError())).toBe(true);
});
test("fails closed for unrelated or malformed errors", () => {
- expect(isImageDescriptionUnavailableError(codedError(500))).toBe(false);
- expect(isImageDescriptionUnavailableError(codedError(503, "2"))).toBe(false);
- expect(isImageDescriptionUnavailableError(codedError(503, "1", "other_error"))).toBe(false);
+ expect(isImageDescriptionUnavailableError(codedImageDescriptionError(500))).toBe(false);
+ expect(isImageDescriptionUnavailableError(codedImageDescriptionError(503, "2"))).toBe(false);
+ expect(
+ isImageDescriptionUnavailableError(codedImageDescriptionError(503, "1", "other_error"))
+ ).toBe(false);
expect(
isImageDescriptionUnavailableError(
Object.assign(new Error("missing contract"), {
diff --git a/frontend/src/services/chatResponseErrors.ts b/frontend/src/services/chatResponseErrors.ts
index 3af0d63ba..e6ae65034 100644
--- a/frontend/src/services/chatResponseErrors.ts
+++ b/frontend/src/services/chatResponseErrors.ts
@@ -4,22 +4,71 @@ const ERROR_CONTRACT_VERSION = "1";
const IMAGE_DESCRIPTION_UNAVAILABLE_ERROR_CODE = "image_description_unavailable";
const IMAGE_DESCRIPTION_UNAVAILABLE_STATUS = 503;
const MAX_ERROR_CAUSE_DEPTH = 4;
+const REQUEST_NOT_DISPATCHED_CODE = "opensecret_request_not_dispatched";
type ErrorResponseMetadata = {
status?: unknown;
headers?: unknown;
cause?: unknown;
+ requestDispatchCode?: unknown;
+ definitelyNotDispatched?: unknown;
};
-export function isImageDescriptionUnavailableError(error: unknown): boolean {
+function errorCauseChain(error: unknown): readonly ErrorResponseMetadata[] {
+ const chain: ErrorResponseMetadata[] = [];
const seen = new Set();
let current = error;
for (let depth = 0; depth < MAX_ERROR_CAUSE_DEPTH; depth += 1) {
- if (typeof current !== "object" || current === null || seen.has(current)) return false;
+ if (typeof current !== "object" || current === null || seen.has(current)) break;
seen.add(current);
-
const metadata = current as ErrorResponseMetadata;
+ chain.push(metadata);
+ current = metadata.cause;
+ }
+
+ return chain;
+}
+
+export function isChatRequestDefinitelyNotDispatchedError(error: unknown): boolean {
+ return errorCauseChain(error).some(
+ (metadata) =>
+ metadata.requestDispatchCode === REQUEST_NOT_DISPATCHED_CODE &&
+ metadata.definitelyNotDispatched === true
+ );
+}
+
+/**
+ * The Responses cancel endpoint returns 400 for an already-terminal race only
+ * after its execution owner is quiescent. Other cancellation failures do not
+ * certify that background work has stopped, even if a separate retrieve sees a
+ * terminal database status.
+ */
+export function isChatResponseCancellationAlreadyTerminalError(error: unknown): boolean {
+ return errorCauseChain(error).some((metadata) => metadata.status === 400);
+}
+
+/**
+ * A non-timeout 4xx, or the explicit image-description pre-acceptance error,
+ * rejected the turn before Responses persistence. The generic error-contract
+ * version only describes the response schema, so other server failures remain
+ * ambiguous.
+ */
+export function isChatResponseDefinitelyRejectedError(error: unknown): boolean {
+ return errorCauseChain(error).some((metadata) => {
+ if (typeof metadata.status !== "number" || metadata.status === 408) return false;
+ if (metadata.status >= 400 && metadata.status < 500) return true;
+ return (
+ metadata.status === IMAGE_DESCRIPTION_UNAVAILABLE_STATUS &&
+ metadata.headers instanceof Headers &&
+ metadata.headers.get(ERROR_CONTRACT_HEADER) === ERROR_CONTRACT_VERSION &&
+ metadata.headers.get(ERROR_CODE_HEADER) === IMAGE_DESCRIPTION_UNAVAILABLE_ERROR_CODE
+ );
+ });
+}
+
+export function isImageDescriptionUnavailableError(error: unknown): boolean {
+ for (const metadata of errorCauseChain(error)) {
if (
metadata.status === IMAGE_DESCRIPTION_UNAVAILABLE_STATUS &&
metadata.headers instanceof Headers &&
@@ -28,8 +77,6 @@ export function isImageDescriptionUnavailableError(error: unknown): boolean {
) {
return true;
}
-
- current = metadata.cause;
}
return false;
diff --git a/frontend/src/services/chatResponseReconciliation.test.ts b/frontend/src/services/chatResponseReconciliation.test.ts
new file mode 100644
index 000000000..e95e7c4ae
--- /dev/null
+++ b/frontend/src/services/chatResponseReconciliation.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, test } from "bun:test";
+import {
+ classifyChatResponseReconciliation,
+ responseIdForChatMessage
+} from "./chatResponseReconciliation";
+
+describe("chat response reconciliation", () => {
+ test("distinguishes durable completion, other terminal states, and live work", () => {
+ expect(classifyChatResponseReconciliation("completed")).toBe("completed");
+ expect(classifyChatResponseReconciliation("failed")).toBe("terminal");
+ expect(classifyChatResponseReconciliation("cancelled")).toBe("terminal");
+ expect(classifyChatResponseReconciliation("incomplete")).toBe("terminal");
+ expect(classifyChatResponseReconciliation("queued")).toBe("pending");
+ expect(classifyChatResponseReconciliation("in_progress")).toBe("pending");
+ expect(classifyChatResponseReconciliation(undefined)).toBe("pending");
+ });
+
+ test("recovers only the response linked to the exact current user turn", () => {
+ expect(
+ responseIdForChatMessage("current-turn", [
+ { id: "old-failure", role: "user", response_id: "response-old" },
+ { id: "other-tab", role: "user", response_id: "response-other" },
+ { id: "current-turn", role: "user", response_id: "response-current" }
+ ])
+ ).toBe("response-current");
+ });
+
+ test("rejects older links, assistant links, and malformed response IDs", () => {
+ expect(
+ responseIdForChatMessage("current-turn", [
+ { id: "old-failure", role: "user", response_id: "response-old" }
+ ])
+ ).toBeUndefined();
+ expect(
+ responseIdForChatMessage("current-turn", [
+ { id: "current-turn", role: "assistant", response_id: "response-current" },
+ { id: "current-turn", role: "user", response_id: 42 }
+ ])
+ ).toBeUndefined();
+ });
+});
diff --git a/frontend/src/services/chatResponseReconciliation.ts b/frontend/src/services/chatResponseReconciliation.ts
new file mode 100644
index 000000000..0eb74376a
--- /dev/null
+++ b/frontend/src/services/chatResponseReconciliation.ts
@@ -0,0 +1,36 @@
+export type ChatResponseReconciliation = "completed" | "terminal" | "pending";
+
+type ChatResponseLinkedConversationItem = Readonly<{
+ id?: string;
+ role?: string;
+ response_id?: unknown;
+}>;
+
+export function classifyChatResponseReconciliation(
+ status: string | null | undefined
+): ChatResponseReconciliation {
+ if (status === "completed") return "completed";
+ if (status === "failed" || status === "cancelled" || status === "incomplete") {
+ return "terminal";
+ }
+ return "pending";
+}
+
+/**
+ * Recovers the response that owns one exact optimistic user item when the
+ * streaming request was accepted but its `response.created` frame never
+ * reached Maple.
+ */
+export function responseIdForChatMessage(
+ messageId: string,
+ polledItems: readonly ChatResponseLinkedConversationItem[]
+): string | undefined {
+ for (const item of polledItems) {
+ if (item.id !== messageId || item.role !== "user") continue;
+ if (typeof item.response_id === "string" && item.response_id.length > 0) {
+ return item.response_id;
+ }
+ }
+
+ return undefined;
+}
diff --git a/frontend/src/services/chatRunQueueHalt.test.ts b/frontend/src/services/chatRunQueueHalt.test.ts
new file mode 100644
index 000000000..d15711ba9
--- /dev/null
+++ b/frontend/src/services/chatRunQueueHalt.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test";
+import {
+ clearChatRunQueueHalt,
+ isChatRunQueueHaltRequested,
+ requestChatRunQueueHalt
+} from "./chatRunQueueHalt";
+
+describe("chat run queue halt", () => {
+ test("keeps Stop intent scoped to one store and outer run token", () => {
+ const firstStore = {};
+ const secondStore = {};
+
+ requestChatRunQueueHalt(firstStore, 7);
+ expect(isChatRunQueueHaltRequested(firstStore, 7)).toBe(true);
+ expect(isChatRunQueueHaltRequested(firstStore, 8)).toBe(false);
+ expect(isChatRunQueueHaltRequested(secondStore, 7)).toBe(false);
+
+ clearChatRunQueueHalt(firstStore, 7);
+ expect(isChatRunQueueHaltRequested(firstStore, 7)).toBe(false);
+ });
+
+ test("clearing one halted run preserves another", () => {
+ const store = {};
+ requestChatRunQueueHalt(store, 1);
+ requestChatRunQueueHalt(store, 2);
+
+ clearChatRunQueueHalt(store, 1);
+ expect(isChatRunQueueHaltRequested(store, 1)).toBe(false);
+ expect(isChatRunQueueHaltRequested(store, 2)).toBe(true);
+ });
+});
diff --git a/frontend/src/services/chatRunQueueHalt.ts b/frontend/src/services/chatRunQueueHalt.ts
new file mode 100644
index 000000000..0b2c2aded
--- /dev/null
+++ b/frontend/src/services/chatRunQueueHalt.ts
@@ -0,0 +1,24 @@
+const haltedRunTokensByStore = new WeakMap>();
+
+/**
+ * Sticky Stop intent for one outer Chat FIFO runner. This is deliberately
+ * separate from the transient cancellation-in-flight UI state: a failed remote
+ * cancellation may be retried, but the current runner must still never promote
+ * another queued turn after the user pressed Stop.
+ */
+export function requestChatRunQueueHalt(store: object, runToken: number): void {
+ const halted = haltedRunTokensByStore.get(store) ?? new Set();
+ halted.add(runToken);
+ haltedRunTokensByStore.set(store, halted);
+}
+
+export function isChatRunQueueHaltRequested(store: object, runToken: number): boolean {
+ return haltedRunTokensByStore.get(store)?.has(runToken) ?? false;
+}
+
+export function clearChatRunQueueHalt(store: object, runToken: number): void {
+ const halted = haltedRunTokensByStore.get(store);
+ if (!halted) return;
+ halted.delete(runToken);
+ if (halted.size === 0) haltedRunTokensByStore.delete(store);
+}
diff --git a/frontend/src/services/chatRuntimeCancellation.ts b/frontend/src/services/chatRuntimeCancellation.ts
new file mode 100644
index 000000000..6dd79c51f
--- /dev/null
+++ b/frontend/src/services/chatRuntimeCancellation.ts
@@ -0,0 +1,23 @@
+import { restoreRegisteredChatTurnBeforeRequest } from "./chatCurrentTurnRegistry";
+import type { ChatRuntimeKey } from "./chatRuntimeStore";
+
+type CancellableChatRuntimeStore = object & {
+ getActiveRunKeys: () => readonly ChatRuntimeKey[];
+ get: (key: ChatRuntimeKey) => Readonly<{ runToken: number | null }> | undefined;
+ cancelRun: (key: ChatRuntimeKey, token: number) => unknown;
+};
+
+/** Synchronously fences every active client runner at an account boundary. */
+export function cancelActiveChatRuntimeRuns(store: CancellableChatRuntimeStore): void {
+ for (const key of store.getActiveRunKeys()) {
+ const token = store.get(key)?.runToken;
+ if (token !== null && token !== undefined) {
+ restoreRegisteredChatTurnBeforeRequest(
+ store,
+ token,
+ "Sending stopped because this account session is closing."
+ );
+ store.cancelRun(key, token);
+ }
+ }
+}
diff --git a/frontend/src/services/chatRuntimeDeletionFence.test.ts b/frontend/src/services/chatRuntimeDeletionFence.test.ts
new file mode 100644
index 000000000..6d9da94d4
--- /dev/null
+++ b/frontend/src/services/chatRuntimeDeletionFence.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, test } from "bun:test";
+import {
+ beginAllChatRuntimeDeletionFence,
+ beginChatActivityGroupDeletionFence,
+ beginChatProjectRuntimeDeletionFence,
+ beginChatRuntimeDeletionFence,
+ isChatRuntimeDeletionPending
+} from "./chatRuntimeDeletionFence";
+import type { ChatRuntimeKey } from "./chatRuntimeStore";
+
+function lookup() {
+ const aliases = new Map();
+ const groups = new Map();
+ return {
+ aliases,
+ groups,
+ resolveKey(key: ChatRuntimeKey) {
+ return aliases.get(key) ?? key;
+ },
+ getActivityGroupId(key: ChatRuntimeKey) {
+ return groups.get(this.resolveKey(key));
+ }
+ };
+}
+
+describe("chat runtime deletion fences", () => {
+ test("fences every runtime while all history is being deleted", () => {
+ const store = lookup();
+ const firstRelease = beginAllChatRuntimeDeletionFence(store);
+ const secondRelease = beginAllChatRuntimeDeletionFence(store);
+ const key = "conversation:any" as ChatRuntimeKey;
+
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(true);
+ firstRelease();
+ firstRelease();
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(true);
+ secondRelease();
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(false);
+ });
+
+ test("fences one runtime through rekey and releases it after deletion settles", () => {
+ const store = lookup();
+ const draftKey = "draft:deleting" as ChatRuntimeKey;
+ const conversationKey = "conversation:deleting" as ChatRuntimeKey;
+ const release = beginChatRuntimeDeletionFence(store, draftKey);
+
+ expect(isChatRuntimeDeletionPending(store, draftKey)).toBe(true);
+ store.aliases.set(draftKey, conversationKey);
+ expect(isChatRuntimeDeletionPending(store, conversationKey)).toBe(true);
+
+ release();
+ expect(isChatRuntimeDeletionPending(store, conversationKey)).toBe(false);
+ });
+
+ test("keeps one runtime fenced until every overlapping lease releases", () => {
+ const store = lookup();
+ const key = "conversation:overlapping" as ChatRuntimeKey;
+ const firstRelease = beginChatRuntimeDeletionFence(store, key);
+ const secondRelease = beginChatRuntimeDeletionFence(store, key);
+
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(true);
+ firstRelease();
+ firstRelease();
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(true);
+ secondRelease();
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(false);
+ });
+
+ test("fences every runtime in a deleting activity group", () => {
+ const store = lookup();
+ const first = "conversation:first" as ChatRuntimeKey;
+ const second = "draft:second" as ChatRuntimeKey;
+ const unrelated = "conversation:unrelated" as ChatRuntimeKey;
+ store.groups.set(first, "project:deleting");
+ store.groups.set(second, "project:deleting");
+ store.groups.set(unrelated, "project:other");
+ const release = beginChatActivityGroupDeletionFence(store, "project:deleting");
+
+ expect(isChatRuntimeDeletionPending(store, first)).toBe(true);
+ expect(isChatRuntimeDeletionPending(store, second)).toBe(true);
+ expect(isChatRuntimeDeletionPending(store, unrelated)).toBe(false);
+
+ release();
+ expect(isChatRuntimeDeletionPending(store, first)).toBe(false);
+ });
+
+ test("keeps an activity group fenced until every overlapping lease releases", () => {
+ const store = lookup();
+ const key = "conversation:project-overlap" as ChatRuntimeKey;
+ store.groups.set(key, "project:overlapping");
+ const firstRelease = beginChatActivityGroupDeletionFence(store, "project:overlapping");
+ const secondRelease = beginChatActivityGroupDeletionFence(store, "project:overlapping");
+
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(true);
+ firstRelease();
+ firstRelease();
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(true);
+ secondRelease();
+ expect(isChatRuntimeDeletionPending(store, key)).toBe(false);
+ });
+
+ test("project deletion also fences exact conversations whose group metadata is unavailable", () => {
+ const store = lookup();
+ const grouped = "conversation:grouped" as ChatRuntimeKey;
+ const metadataUnavailable = "conversation:metadata-unavailable" as ChatRuntimeKey;
+ const unrelated = "conversation:unrelated" as ChatRuntimeKey;
+ store.groups.set(grouped, "project:deleting");
+ store.groups.set(metadataUnavailable, null);
+ store.groups.set(unrelated, null);
+
+ const release = beginChatProjectRuntimeDeletionFence(store, "project:deleting", [
+ metadataUnavailable
+ ]);
+
+ expect(isChatRuntimeDeletionPending(store, grouped)).toBe(true);
+ expect(isChatRuntimeDeletionPending(store, metadataUnavailable)).toBe(true);
+ expect(isChatRuntimeDeletionPending(store, unrelated)).toBe(false);
+ release();
+ release();
+ expect(isChatRuntimeDeletionPending(store, grouped)).toBe(false);
+ expect(isChatRuntimeDeletionPending(store, metadataUnavailable)).toBe(false);
+ });
+});
diff --git a/frontend/src/services/chatRuntimeDeletionFence.ts b/frontend/src/services/chatRuntimeDeletionFence.ts
new file mode 100644
index 000000000..bfa195e81
--- /dev/null
+++ b/frontend/src/services/chatRuntimeDeletionFence.ts
@@ -0,0 +1,108 @@
+import type { ChatRuntimeKey } from "./chatRuntimeStore";
+
+type ChatRuntimeDeletionLookup = object & {
+ resolveKey: (key: ChatRuntimeKey) => ChatRuntimeKey;
+ getActivityGroupId: (key: ChatRuntimeKey) => string | null | undefined;
+};
+
+type ChatRuntimeDeletionFences = {
+ keys: Map;
+ activityGroups: Map;
+ all: number;
+};
+
+const deletionFencesByStore = new WeakMap();
+
+function fencesFor(store: object): ChatRuntimeDeletionFences {
+ const existing = deletionFencesByStore.get(store);
+ if (existing) return existing;
+ const created = {
+ keys: new Map(),
+ activityGroups: new Map(),
+ all: 0
+ };
+ deletionFencesByStore.set(store, created);
+ return created;
+}
+
+export function beginAllChatRuntimeDeletionFence(store: object): () => void {
+ const fences = fencesFor(store);
+ fences.all += 1;
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ fences.all = Math.max(0, fences.all - 1);
+ };
+}
+
+export function beginChatRuntimeDeletionFence(
+ store: ChatRuntimeDeletionLookup,
+ key: ChatRuntimeKey
+): () => void {
+ const fences = fencesFor(store);
+ const fencedKey = store.resolveKey(key);
+ fences.keys.set(fencedKey, (fences.keys.get(fencedKey) ?? 0) + 1);
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ const remaining = (fences.keys.get(fencedKey) ?? 0) - 1;
+ if (remaining > 0) fences.keys.set(fencedKey, remaining);
+ else fences.keys.delete(fencedKey);
+ };
+}
+
+export function beginChatActivityGroupDeletionFence(
+ store: ChatRuntimeDeletionLookup,
+ activityGroupId: string
+): () => void {
+ const fences = fencesFor(store);
+ fences.activityGroups.set(activityGroupId, (fences.activityGroups.get(activityGroupId) ?? 0) + 1);
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ const remaining = (fences.activityGroups.get(activityGroupId) ?? 0) - 1;
+ if (remaining > 0) fences.activityGroups.set(activityGroupId, remaining);
+ else fences.activityGroups.delete(activityGroupId);
+ };
+}
+
+/**
+ * Fences both the runtime's cached activity group and exact conversation keys
+ * discovered from the server. Exact keys cover runtimes whose metadata could
+ * not be loaded, and therefore do not yet expose their project group locally.
+ */
+export function beginChatProjectRuntimeDeletionFence(
+ store: ChatRuntimeDeletionLookup,
+ activityGroupId: string,
+ conversationKeys: readonly ChatRuntimeKey[]
+): () => void {
+ const releases = [
+ beginChatActivityGroupDeletionFence(store, activityGroupId),
+ ...conversationKeys.map((key) => beginChatRuntimeDeletionFence(store, key))
+ ];
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ for (const release of releases.reverse()) release();
+ };
+}
+
+export function isChatRuntimeDeletionPending(
+ store: ChatRuntimeDeletionLookup,
+ key: ChatRuntimeKey
+): boolean {
+ const fences = deletionFencesByStore.get(store);
+ if (!fences) return false;
+ if (fences.all > 0) return true;
+
+ const canonicalKey = store.resolveKey(key);
+ for (const [fencedKey, leaseCount] of fences.keys) {
+ if (leaseCount > 0 && store.resolveKey(fencedKey) === canonicalKey) return true;
+ }
+ const activityGroupId = store.getActivityGroupId(canonicalKey);
+ return Boolean(activityGroupId && (fences.activityGroups.get(activityGroupId) ?? 0) > 0);
+}
diff --git a/frontend/src/services/chatRuntimeStore.test.ts b/frontend/src/services/chatRuntimeStore.test.ts
index 3d4b1f705..f38774a1e 100644
--- a/frontend/src/services/chatRuntimeStore.test.ts
+++ b/frontend/src/services/chatRuntimeStore.test.ts
@@ -6,6 +6,7 @@ import {
type ChatRuntimeRunUpdater,
type ChatRuntimeSnapshot
} from "./chatRuntimeStore";
+import { chatCursorAfterSendFailure } from "./chatSendFailureRecovery";
type Conversation = { id: string; title: string };
type Message = { id: string; text: string; status: "streaming" | "completed" };
@@ -162,6 +163,9 @@ describe("ChatRuntimeStore", () => {
input: "updated A while offscreen",
attachmentIds: ["a.png"]
});
+ const snapshots = store.getSnapshots();
+ expect(Object.isFrozen(snapshots)).toBe(true);
+ expect(new Set(snapshots.map((snapshot) => snapshot.key))).toEqual(new Set([A, B]));
store.select(A);
expect(store.getActive()?.composer.input).toBe("updated A while offscreen");
@@ -444,6 +448,38 @@ describe("ChatRuntimeStore", () => {
});
});
+ test("can reconcile durable completion while aborting the stale local stream", () => {
+ const store = createStore();
+ store.select(A);
+ const run = store.beginRun(A);
+ store.updateForRun(A, run.token, (snapshot) => ({
+ ...snapshot,
+ lastSeenItemId: "persisted-user"
+ }));
+ store.setCurrentResponseId(A, run.token, "completed-response");
+ store.setAssistantStreaming(A, run.token, true);
+
+ expect(
+ store.completeRunAndAbort(A, run.token, (snapshot) => ({
+ ...snapshot,
+ lastSeenItemId: chatCursorAfterSendFailure({
+ currentCursor: snapshot.lastSeenItemId,
+ optimisticMessageId: "persisted-user",
+ previousCursor: undefined,
+ responseCreated: Boolean(snapshot.currentResponseId)
+ })
+ }))
+ ).toBe(true);
+ expect(run.signal.aborted).toBe(true);
+ expect(store.get(A)).toMatchObject({
+ isGenerating: false,
+ assistantStreaming: false,
+ currentResponseId: undefined,
+ lastSeenItemId: "persisted-user",
+ runToken: null
+ });
+ });
+
test("stale run updates and completion cannot clear a replacement run", () => {
const store = createStore();
store.select(A);
@@ -870,6 +906,39 @@ describe("ChatRuntimeStore", () => {
expect(store.deleteActivityGroup("project-delete")).toEqual([]);
});
+ test("clears every runtime and resource while keeping the store reusable", () => {
+ const disposed: Array<{ key: string; reason: string }> = [];
+ const store = createStore({
+ disposeEntry: (snapshot, reason) => disposed.push({ key: snapshot.key, reason })
+ });
+ const draft = createChatDraftKey("clear-all-draft");
+ store.select(draft, { composer: { input: "unsent", attachmentIds: ["blob"] } });
+ store.rememberDraftKey(null, draft);
+ const active = store.beginRun(A, { groupId: "project-clear" });
+ const unread = store.beginRun(B, { groupId: "project-clear" });
+ expect(store.completeRun(B, unread.token)).toBe(true);
+ store.claimVisibleChat({}, A);
+
+ const cleared = store.clearAll();
+
+ expect(new Set(cleared)).toEqual(new Set([draft, A, B]));
+ expect(Object.isFrozen(cleared)).toBe(true);
+ expect(active.signal.aborted).toBe(true);
+ expect(store.getActive()).toBeUndefined();
+ expect(store.getActiveRunKeys()).toEqual([]);
+ expect(store.getCompletedUnreadKeys()).toEqual([]);
+ expect(store.getRememberedDraftKey(null)).toBeNull();
+ expect(new Set(disposed)).toEqual(
+ new Set([
+ { key: draft, reason: "deleted" },
+ { key: A, reason: "deleted" },
+ { key: B, reason: "deleted" }
+ ])
+ );
+
+ expect(store.select(createChatDraftKey("after-clear"))).toBeDefined();
+ });
+
test("dispose resets unread and visible ownership state and notifies subscribers", () => {
const store = createStore();
const emptyActiveSnapshot = store.getActiveRunKeys();
diff --git a/frontend/src/services/chatRuntimeStore.ts b/frontend/src/services/chatRuntimeStore.ts
index e3991300e..18cc2cdd7 100644
--- a/frontend/src/services/chatRuntimeStore.ts
+++ b/frontend/src/services/chatRuntimeStore.ts
@@ -368,6 +368,42 @@ export class ChatRuntimeStore {
return Object.freeze(Array.from(keys));
}
+ /** Clears every account-scoped runtime while keeping this provider's store reusable. */
+ clearAll(): readonly ChatRuntimeKey[] {
+ this.assertNotDisposed();
+ const currentEntries = Array.from(this.entries.entries());
+ const keys = Object.freeze(currentEntries.map(([key]) => key));
+ const hadState =
+ currentEntries.length > 0 ||
+ this.aliases.size > 0 ||
+ this.completedUnreadGroups.size > 0 ||
+ this.rememberedDraftKeys.size > 0 ||
+ this.activeKey !== null ||
+ this.visibleChatLease !== null;
+ if (!hadState) return keys;
+
+ this.entries.clear();
+ this.aliases.clear();
+ this.completedUnreadGroups.clear();
+ this.rememberedDraftKeys.clear();
+ this.activeKey = null;
+ this.visibleChatLease = null;
+ this.visibleChatKey = null;
+
+ this.runAll([
+ ...currentEntries.flatMap(([, entry]) =>
+ entry.activeRun ? [() => entry.activeRun!.controller.abort()] : []
+ ),
+ ...currentEntries.map(
+ ([, entry]) =>
+ () =>
+ this.disposeEntry?.(entry.snapshot, "deleted")
+ ),
+ () => this.publish(true, true)
+ ]);
+ return keys;
+ }
+
subscribeKey(key: ChatRuntimeKey, listener: () => void): () => void {
let lastSnapshot = this.get(key);
return this.subscribe(() => {
@@ -410,6 +446,11 @@ export class ChatRuntimeStore {
return this.entries.get(this.resolveKey(key))?.snapshot;
}
+ /** Read-only account snapshot used for aggregate resource admission checks. */
+ getSnapshots(): readonly ChatRuntimeSnapshot[] {
+ return Object.freeze(Array.from(this.entries.values(), (entry) => entry.snapshot));
+ }
+
ensure(
key: ChatRuntimeKey,
initial: ChatRuntimeInitialState = {}
@@ -580,11 +621,25 @@ export class ChatRuntimeStore {
return this.settleRun(key, token, true, updater);
}
+ /**
+ * Reconciles a durably completed response while terminating a stream that
+ * can no longer provide useful ownership updates. Ownership is cleared before
+ * abort listeners run, preserving the same stale-token guarantee as cancel.
+ */
+ completeRunAndAbort(
+ key: ChatRuntimeKey,
+ token: number,
+ updater?: ChatRuntimeRunUpdater
+ ): boolean {
+ return this.settleRun(key, token, true, updater, true);
+ }
+
private settleRun(
key: ChatRuntimeKey,
token: number,
completedSuccessfully: boolean,
- updater?: ChatRuntimeRunUpdater
+ updater?: ChatRuntimeRunUpdater,
+ abortController = false
): boolean {
if (this.disposed) return false;
const canonicalKey = this.resolveKey(key);
@@ -593,6 +648,7 @@ export class ChatRuntimeStore {
const completed = updater ? { ...entry.snapshot, ...updater(entry.snapshot) } : entry.snapshot;
const completedGroupId = entry.activeRun.groupId;
+ const run = entry.activeRun;
entry.activeRun = null;
entry.snapshot = this.updatedSnapshot(
entry.snapshot,
@@ -618,6 +674,7 @@ export class ChatRuntimeStore {
}
this.touch(entry);
this.evictInactiveCompletedEntries();
+ if (abortController) run.controller.abort();
this.publish();
return true;
}
diff --git a/frontend/src/services/chatSendFailureRecovery.test.ts b/frontend/src/services/chatSendFailureRecovery.test.ts
index bfc14c7fb..befbf0e48 100644
--- a/frontend/src/services/chatSendFailureRecovery.test.ts
+++ b/frontend/src/services/chatSendFailureRecovery.test.ts
@@ -1,5 +1,54 @@
import { describe, expect, test } from "bun:test";
-import { recoverFailedSendAfterDestinationAdoption } from "./chatSendFailureRecovery";
+import {
+ chatCursorAfterSendFailure,
+ recoverFailedSendAfterDestinationAdoption
+} from "./chatSendFailureRecovery";
+
+describe("chatCursorAfterSendFailure", () => {
+ test("rewinds an unconfirmed optimistic cursor to the previous durable item", () => {
+ expect(
+ chatCursorAfterSendFailure({
+ currentCursor: "optimistic-user",
+ optimisticMessageId: "optimistic-user",
+ previousCursor: "previous-item",
+ responseCreated: false
+ })
+ ).toBe("previous-item");
+ });
+
+ test("keeps the persisted user cursor after response.created", () => {
+ expect(
+ chatCursorAfterSendFailure({
+ currentCursor: "optimistic-user",
+ optimisticMessageId: "optimistic-user",
+ previousCursor: "previous-item",
+ responseCreated: true
+ })
+ ).toBe("optimistic-user");
+ });
+
+ test("never rewinds a cursor that already advanced beyond the optimistic user", () => {
+ expect(
+ chatCursorAfterSendFailure({
+ currentCursor: "assistant-item",
+ optimisticMessageId: "optimistic-user",
+ previousCursor: "previous-item",
+ responseCreated: false
+ })
+ ).toBe("assistant-item");
+ });
+
+ test("rewinds an unconfirmed first turn to an empty cursor", () => {
+ expect(
+ chatCursorAfterSendFailure({
+ currentCursor: "optimistic-user",
+ optimisticMessageId: "optimistic-user",
+ previousCursor: undefined,
+ responseCreated: false
+ })
+ ).toBeUndefined();
+ });
+});
describe("recoverFailedSendAfterDestinationAdoption", () => {
test("keeps destination B composer resources while retaining failed source A", () => {
diff --git a/frontend/src/services/chatSendFailureRecovery.ts b/frontend/src/services/chatSendFailureRecovery.ts
index bf97486a3..db8af7d99 100644
--- a/frontend/src/services/chatSendFailureRecovery.ts
+++ b/frontend/src/services/chatSendFailureRecovery.ts
@@ -3,6 +3,21 @@ export type AdoptedDestinationFailureRecovery = Readonly<{
composer: TComposer;
}>;
+export function chatCursorAfterSendFailure({
+ currentCursor,
+ optimisticMessageId,
+ previousCursor,
+ responseCreated
+}: {
+ currentCursor: string | undefined;
+ optimisticMessageId: string;
+ previousCursor: string | undefined;
+ responseCreated: boolean;
+}): string | undefined {
+ if (currentCursor !== optimisticMessageId || responseCreated) return currentCursor;
+ return previousCursor;
+}
+
/**
* Once a source run adopts an independently selected destination runtime, that
* destination owns its composer and object URLs. A later source-send failure
diff --git a/frontend/src/services/chatStoppingRuntimeRegistry.test.ts b/frontend/src/services/chatStoppingRuntimeRegistry.test.ts
new file mode 100644
index 000000000..2d5a4edad
--- /dev/null
+++ b/frontend/src/services/chatStoppingRuntimeRegistry.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test";
+import { chatStoppingRuntimeRegistryFor } from "./chatStoppingRuntimeRegistry";
+import type { ChatRuntimeKey } from "./chatRuntimeStore";
+
+describe("chat stopping runtime registry", () => {
+ test("clears a Stop token after its run rekeys from draft to conversation", () => {
+ const registry = chatStoppingRuntimeRegistryFor({});
+ const draftKey = "draft:one" as ChatRuntimeKey;
+ const conversationKey = "conversation:one" as ChatRuntimeKey;
+
+ registry.add(draftKey, 7);
+ registry.delete(conversationKey, 7);
+
+ expect(registry.getEntries().size).toBe(0);
+ });
+
+ test("deleting one run leaves other Stop tokens intact", () => {
+ const registry = chatStoppingRuntimeRegistryFor({});
+ const firstKey = "draft:first" as ChatRuntimeKey;
+ const secondKey = "draft:second" as ChatRuntimeKey;
+
+ registry.add(firstKey, 1);
+ registry.add(secondKey, 2);
+ registry.delete(firstKey, 1);
+
+ expect(registry.getEntries().get(firstKey)).toBeUndefined();
+ expect(registry.getEntries().get(secondKey)).toEqual(new Set([2]));
+ });
+
+ test("publishes only for effective mutations", () => {
+ const registry = chatStoppingRuntimeRegistryFor({});
+ const key = "draft:one" as ChatRuntimeKey;
+ let notifications = 0;
+ registry.subscribe(() => {
+ notifications += 1;
+ });
+
+ registry.add(key, 3);
+ registry.add(key, 3);
+ registry.delete(key, 99);
+ registry.delete(key, 3);
+
+ expect(notifications).toBe(2);
+ });
+});
diff --git a/frontend/src/services/chatStoppingRuntimeRegistry.ts b/frontend/src/services/chatStoppingRuntimeRegistry.ts
new file mode 100644
index 000000000..398133530
--- /dev/null
+++ b/frontend/src/services/chatStoppingRuntimeRegistry.ts
@@ -0,0 +1,62 @@
+import type { ChatRuntimeKey } from "./chatRuntimeStore";
+
+export type ChatStoppingRuntimeRegistry = Readonly<{
+ add: (key: ChatRuntimeKey, runToken: number) => void;
+ delete: (key: ChatRuntimeKey, runToken: number) => void;
+ getEntries: () => ReadonlyMap>;
+ getSnapshot: () => number;
+ subscribe: (listener: () => void) => () => void;
+}>;
+
+const registries = new WeakMap();
+
+export function chatStoppingRuntimeRegistryFor(runtimeOwner: object): ChatStoppingRuntimeRegistry {
+ const existing = registries.get(runtimeOwner);
+ if (existing) return existing;
+
+ let entries: ReadonlyMap> = new Map();
+ let revision = 0;
+ const listeners = new Set<() => void>();
+ const publish = () => {
+ revision += 1;
+ for (const listener of listeners) listener();
+ };
+ const registry: ChatStoppingRuntimeRegistry = {
+ add: (key, runToken) => {
+ const currentTokens = entries.get(key);
+ if (currentTokens?.has(runToken)) return;
+ const nextEntries = new Map(entries);
+ nextEntries.set(key, new Set(currentTokens).add(runToken));
+ entries = nextEntries;
+ publish();
+ },
+ delete: (_key, runToken) => {
+ // A new conversation rekeys its active run from a draft key to a
+ // conversation key. Run tokens are unique within a runtime store, so
+ // remove the token from whichever pre-rekey key still owns it.
+ let changed = false;
+ const nextEntries = new Map>();
+ for (const [entryKey, currentTokens] of entries) {
+ if (!currentTokens.has(runToken)) {
+ nextEntries.set(entryKey, currentTokens);
+ continue;
+ }
+ changed = true;
+ const nextTokens = new Set(currentTokens);
+ nextTokens.delete(runToken);
+ if (nextTokens.size > 0) nextEntries.set(entryKey, nextTokens);
+ }
+ if (!changed) return;
+ entries = nextEntries;
+ publish();
+ },
+ getEntries: () => entries,
+ getSnapshot: () => revision,
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ }
+ };
+ registries.set(runtimeOwner, registry);
+ return registry;
+}
diff --git a/frontend/src/services/chatUnresolvedResponseOwnership.test.ts b/frontend/src/services/chatUnresolvedResponseOwnership.test.ts
new file mode 100644
index 000000000..c926deb00
--- /dev/null
+++ b/frontend/src/services/chatUnresolvedResponseOwnership.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test";
+import {
+ clearUnresolvedChatResponseMessage,
+ getUnresolvedChatResponseMessage,
+ registerUnresolvedChatResponseMessage
+} from "./chatUnresolvedResponseOwnership";
+
+describe("chat unresolved response ownership", () => {
+ test("tracks exactly one optimistic message per active run", () => {
+ const store = {};
+ registerUnresolvedChatResponseMessage(store, 7, "current-turn");
+
+ expect(getUnresolvedChatResponseMessage(store, 7)).toBe("current-turn");
+ expect(getUnresolvedChatResponseMessage(store, 8)).toBeUndefined();
+ });
+
+ test("a stale clear cannot remove replacement ownership", () => {
+ const store = {};
+ registerUnresolvedChatResponseMessage(store, 9, "first-turn");
+ registerUnresolvedChatResponseMessage(store, 9, "replacement-turn");
+
+ clearUnresolvedChatResponseMessage(store, 9, "first-turn");
+ expect(getUnresolvedChatResponseMessage(store, 9)).toBe("replacement-turn");
+
+ clearUnresolvedChatResponseMessage(store, 9, "replacement-turn");
+ expect(getUnresolvedChatResponseMessage(store, 9)).toBeUndefined();
+ });
+
+ test("isolates stores with identical run tokens", () => {
+ const firstStore = {};
+ const secondStore = {};
+ registerUnresolvedChatResponseMessage(firstStore, 1, "first");
+ registerUnresolvedChatResponseMessage(secondStore, 1, "second");
+
+ clearUnresolvedChatResponseMessage(firstStore, 1);
+ expect(getUnresolvedChatResponseMessage(firstStore, 1)).toBeUndefined();
+ expect(getUnresolvedChatResponseMessage(secondStore, 1)).toBe("second");
+ });
+});
diff --git a/frontend/src/services/chatUnresolvedResponseOwnership.ts b/frontend/src/services/chatUnresolvedResponseOwnership.ts
new file mode 100644
index 000000000..dc1cf92d7
--- /dev/null
+++ b/frontend/src/services/chatUnresolvedResponseOwnership.ts
@@ -0,0 +1,41 @@
+const unresolvedResponseMessagesByStore = new WeakMap>();
+
+function messagesFor(store: object): Map {
+ const existing = unresolvedResponseMessagesByStore.get(store);
+ if (existing) return existing;
+ const created = new Map();
+ unresolvedResponseMessagesByStore.set(store, created);
+ return created;
+}
+
+/**
+ * Records the one optimistic user-message UUID owned by an active run before
+ * its Responses POST is dispatched. Polling must use this exact UUID rather
+ * than adopting any older incomplete transcript row.
+ */
+export function registerUnresolvedChatResponseMessage(
+ store: object,
+ runToken: number,
+ messageId: string
+): void {
+ messagesFor(store).set(runToken, messageId);
+}
+
+export function getUnresolvedChatResponseMessage(
+ store: object,
+ runToken: number
+): string | undefined {
+ return unresolvedResponseMessagesByStore.get(store)?.get(runToken);
+}
+
+export function clearUnresolvedChatResponseMessage(
+ store: object,
+ runToken: number,
+ expectedMessageId?: string
+): void {
+ const messages = unresolvedResponseMessagesByStore.get(store);
+ if (!messages) return;
+ if (expectedMessageId !== undefined && messages.get(runToken) !== expectedMessageId) return;
+ messages.delete(runToken);
+ if (messages.size === 0) unresolvedResponseMessagesByStore.delete(store);
+}
From 488388991df5c72428b2c8b9da5fb8b9e82b0b80 Mon Sep 17 00:00:00 2001
From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Date: Thu, 3 Sep 2026 18:14:17 +0000
Subject: [PATCH 4/5] fix(chat): quiesce work before destructive transitions
---
frontend/src/components/ChatHistoryList.tsx | 91 ++++-
.../components/GuestPaymentWarningDialog.tsx | 25 +-
frontend/src/components/ProjectDetailView.tsx | 131 +++++--
.../src/components/RootRuntimeLayout.test.tsx | 104 ++++-
frontend/src/components/RootRuntimeLayout.tsx | 5 +-
frontend/src/components/VerificationModal.tsx | 25 +-
.../settings/DeleteAccountSettings.tsx | 39 +-
.../components/settings/HistorySettings.tsx | 37 +-
.../components/settings/SettingsLayout.tsx | 28 +-
.../chatHistoryDeletionQuiescence.test.ts | 360 ++++++++++++++++++
.../services/chatHistoryDeletionQuiescence.ts | 279 ++++++++++++++
11 files changed, 1082 insertions(+), 42 deletions(-)
create mode 100644 frontend/src/services/chatHistoryDeletionQuiescence.test.ts
create mode 100644 frontend/src/services/chatHistoryDeletionQuiescence.ts
diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx
index c086dabb1..61905ff98 100644
--- a/frontend/src/components/ChatHistoryList.tsx
+++ b/frontend/src/components/ChatHistoryList.tsx
@@ -48,17 +48,29 @@ import {
type NewChatNavigationDetail
} from "@/services/chatRuntimeNavigation";
import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
+import { useOpenAI } from "@/ai/useOpenAi";
import {
resumeOrCreateChatDraftKey,
rootChatDraftKeyAfterProjectDeletion
} from "@/services/chatDraftSelection";
import { createConversationChatKey } from "@/services/chatRuntimeStore";
+import {
+ beginAllChatRuntimeDeletionFence,
+ beginChatProjectRuntimeDeletionFence,
+ beginChatRuntimeDeletionFence
+} from "@/services/chatRuntimeDeletionFence";
import {
INITIAL_CHAT_HISTORY_RETRY_COUNT,
conversationHistoryQueryKey,
initialChatHistoryPage,
shouldLoadConversationHistory
} from "@/services/chatHistoryAccountScope";
+import {
+ cancelChatResponseForHistoryDeletion,
+ quiesceChatRuntimeRunsForHistoryDeletion,
+ settleChatRuntimeAfterHistoryCancellation
+} from "@/services/chatHistoryDeletionQuiescence";
+import { assertChatAccountCredential } from "@/services/chatAccountCredential";
const MAX_PROJECTS = 10;
/** Lucide default; keep sidebar list icons visually consistent. */
@@ -100,6 +112,7 @@ export function ChatHistoryList({
containerRef
}: ChatHistoryListProps) {
const opensecret = useOpenSecret();
+ const openai = useOpenAI();
const router = useRouter();
const queryClient = useQueryClient();
const { selectedProjectId, setSelectedProjectId } = useSelectedProjectState();
@@ -684,7 +697,22 @@ export function ChatHistoryList({
// Handle conversation deletion via API
const handleDeleteConversation = useCallback(
async (conversationId: string) => {
+ const expectedUserId = opensecret.auth.user?.user.id;
+ const releaseDeletionFence = beginChatRuntimeDeletionFence(
+ runtimeStore,
+ createConversationChatKey(conversationId)
+ );
try {
+ assertChatAccountCredential(expectedUserId);
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ keys: [createConversationChatKey(conversationId)],
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+ assertChatAccountCredential(expectedUserId);
await opensecret.deleteConversation(conversationId);
if (conversationId === currentChatId) {
@@ -704,9 +732,11 @@ export function ChatHistoryList({
}
} catch (error) {
console.error("Error deleting conversation:", error);
+ } finally {
+ releaseDeletionFence();
}
},
- [currentChatId, invalidateConversationData, opensecret, runtimeStore]
+ [currentChatId, invalidateConversationData, openai, opensecret, runtimeStore]
);
const MAX_SELECTION = 20;
@@ -734,11 +764,26 @@ export function ChatHistoryList({
if (selectedIds.size === 0) return;
setIsBulkDeleting(true);
+ const releaseDeletionFences = Array.from(selectedIds, (id) =>
+ beginChatRuntimeDeletionFence(runtimeStore, createConversationChatKey(id))
+ );
try {
+ const expectedUserId = opensecret.auth.user?.user.id;
+ assertChatAccountCredential(expectedUserId);
const idsToDelete = Array.from(selectedIds);
+ const keysToDelete = idsToDelete.map((id) => createConversationChatKey(id));
const deletedIds = new Set();
if (opensecret) {
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ keys: keysToDelete,
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+ assertChatAccountCredential(expectedUserId);
const result = await opensecret.batchDeleteConversations(idsToDelete);
result.data.filter((item) => item.deleted).forEach((item) => deletedIds.add(item.id));
@@ -768,10 +813,12 @@ export function ChatHistoryList({
} catch (error) {
console.error("Error bulk deleting chats:", error);
} finally {
+ for (const releaseDeletionFence of releaseDeletionFences) releaseDeletionFence();
setIsBulkDeleting(false);
}
}, [
selectedIds,
+ openai,
opensecret,
invalidateConversationData,
currentChatId,
@@ -985,13 +1032,15 @@ export function ChatHistoryList({
const handleDeleteProject = useCallback(async () => {
if (!selectedProject) return;
let replacementDispatched = false;
+ const projectConversationKeys = new Set>();
const projectAwayFromDeletedProject = () => {
if (replacementDispatched) return;
const deletingProjectedChat =
selectedProjectId === selectedProject.id ||
(currentChatId
- ? runtimeStore.getActivityGroupId(createConversationChatKey(currentChatId)) ===
- selectedProject.id
+ ? projectConversationKeys.has(createConversationChatKey(currentChatId)) ||
+ runtimeStore.getActivityGroupId(createConversationChatKey(currentChatId)) ===
+ selectedProject.id
: false);
if (!deletingProjectedChat) return;
@@ -1015,7 +1064,38 @@ export function ChatHistoryList({
window.dispatchEvent(new Event("projectselected"));
};
+ // Until server membership is known, conservatively pause every local
+ // runner. Replace this short discovery fence synchronously with exact-key
+ // plus activity-group fences before the destructive request starts.
+ const releaseDiscoveryFence = beginAllChatRuntimeDeletionFence(runtimeStore);
+ let releaseDeletionFence: (() => void) | null = null;
try {
+ const expectedUserId = opensecret.auth.user?.user.id;
+ assertChatAccountCredential(expectedUserId);
+ const projectConversations = await listAllConversations(opensecret, {
+ project_id: selectedProject.id
+ });
+ for (const conversation of projectConversations) {
+ projectConversationKeys.add(createConversationChatKey(conversation.id));
+ }
+ releaseDeletionFence = beginChatProjectRuntimeDeletionFence(
+ runtimeStore,
+ selectedProject.id,
+ Array.from(projectConversationKeys)
+ );
+ releaseDiscoveryFence();
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ keys: Array.from(projectConversationKeys),
+ activityGroupId: selectedProject.id,
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+
+ assertChatAccountCredential(expectedUserId);
await opensecret.deleteConversationProject(selectedProject.id);
projectAwayFromDeletedProject();
@@ -1031,16 +1111,21 @@ export function ChatHistoryList({
projectAwayFromDeletedProject();
// A selected chat in this project has now committed its replacement;
// grouped background runtimes can be aborted and discarded safely.
+ for (const key of projectConversationKeys) runtimeStore.delete(key);
runtimeStore.deleteActivityGroup(selectedProject.id);
}
} catch (error) {
console.error("Error deleting project:", error);
throw error;
+ } finally {
+ releaseDeletionFence?.();
+ releaseDiscoveryFence();
}
}, [
currentChatId,
expandedProjectId,
invalidateConversationData,
+ openai,
opensecret,
selectedProject,
selectedProjectId,
diff --git a/frontend/src/components/GuestPaymentWarningDialog.tsx b/frontend/src/components/GuestPaymentWarningDialog.tsx
index 1d4b8d927..cf937b14e 100644
--- a/frontend/src/components/GuestPaymentWarningDialog.tsx
+++ b/frontend/src/components/GuestPaymentWarningDialog.tsx
@@ -18,6 +18,9 @@ import {
import { resetWorkspaceModePreference } from "@/services/workspaceModePreference";
import { useState } from "react";
import { getBillingService } from "@/billing/billingService";
+import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
+import { beginAllChatRuntimeDeletionFence } from "@/services/chatRuntimeDeletionFence";
+import { assertChatAccountCredential } from "@/services/chatAccountCredential";
interface GuestPaymentWarningDialogProps {
open: boolean;
@@ -28,6 +31,7 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa
const navigate = useNavigate();
const os = useOpenSecret();
const queryClient = useQueryClient();
+ const runtimeStore = useChatRuntimeStore();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [logoutError, setLogoutError] = useState(null);
@@ -38,25 +42,42 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa
const handleLogout = async () => {
setLogoutError(null);
setIsLoggingOut(true);
+ const releaseChatFence = beginAllChatRuntimeDeletionFence(runtimeStore);
let operationBlock: Awaited> | null = null;
let signedOut = false;
let nativeAuthCleared = false;
const userId = os.auth.user?.user.id;
+ try {
+ assertChatAccountCredential(userId);
+ } catch (error) {
+ console.error("Account changed before sign out:", error);
+ releaseChatFence();
+ setLogoutError("Your account changed in another window. Refresh Maple before signing out.");
+ setIsLoggingOut(false);
+ return;
+ }
+
try {
operationBlock = await stopAgentRuntimeForUser(userId);
} catch (error) {
console.error("Error stopping Agent Mode:", error);
+ releaseChatFence();
setLogoutError("Maple couldn't stop Agent Mode. Please try logging out again.");
setIsLoggingOut(false);
return;
}
try {
+ assertChatAccountCredential(userId);
// Credential reset is a required part of logout.
const { proxyService } = await import("@/services/proxyService");
- await proxyService.stopAndResetProxy(userId, os.deleteApiKey);
+ await proxyService.stopAndResetProxy(userId, (name) => {
+ assertChatAccountCredential(userId);
+ return os.deleteApiKey(name);
+ });
+ assertChatAccountCredential(userId);
// Third-party billing tokens outlive the OpenSecret browser session. If
// one survives logout, the next account can briefly query billing as the
// previous user until that token expires.
@@ -68,6 +89,7 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa
await clearMapleApiAuthForUser(userId);
nativeAuthCleared = true;
+ assertChatAccountCredential(userId);
await os.signOut();
signedOut = true;
resetWorkspaceModePreference();
@@ -79,6 +101,7 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa
);
} finally {
if (!signedOut) {
+ releaseChatFence();
if (nativeAuthCleared) {
try {
await restoreMapleApiAuthForUser(userId);
diff --git a/frontend/src/components/ProjectDetailView.tsx b/frontend/src/components/ProjectDetailView.tsx
index 0c58b3705..286d83b36 100644
--- a/frontend/src/components/ProjectDetailView.tsx
+++ b/frontend/src/components/ProjectDetailView.tsx
@@ -46,18 +46,30 @@ import { RenameChatDialog } from "@/components/RenameChatDialog";
import { DeleteChatDialog } from "@/components/DeleteChatDialog";
import { BulkDeleteDialog } from "@/components/BulkDeleteDialog";
import { MoveChatsDialog } from "@/components/MoveChatsDialog";
-import { listAllConversationProjects } from "@/utils/paginatedLists";
+import { listAllConversationProjects, listAllConversations } from "@/utils/paginatedLists";
import { usePersistentSidebarState } from "@/contexts/PersistentHomeNavigationContext";
import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
+import { useOpenAI } from "@/ai/useOpenAi";
import {
resumeOrCreateChatDraftKey,
rootChatDraftKeyAfterProjectDeletion
} from "@/services/chatDraftSelection";
import { createConversationChatKey } from "@/services/chatRuntimeStore";
+import {
+ beginAllChatRuntimeDeletionFence,
+ beginChatProjectRuntimeDeletionFence,
+ beginChatRuntimeDeletionFence
+} from "@/services/chatRuntimeDeletionFence";
import {
createChatHistoryEntryForDraft,
type NewChatNavigationDetail
} from "@/services/chatRuntimeNavigation";
+import {
+ cancelChatResponseForHistoryDeletion,
+ quiesceChatRuntimeRunsForHistoryDeletion,
+ settleChatRuntimeAfterHistoryCancellation
+} from "@/services/chatHistoryDeletionQuiescence";
+import { assertChatAccountCredential } from "@/services/chatAccountCredential";
const PROJECT_PAGE_SIZE = 20;
const MAX_SELECTION = 20;
@@ -154,6 +166,7 @@ function ProjectInstructionsDialog({
export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
const os = useOpenSecret();
+ const openai = useOpenAI();
const userId = os.auth.user?.user.id;
const queryClient = useQueryClient();
const isMobile = useIsMobile();
@@ -386,20 +399,51 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
);
const handleDeleteProject = useCallback(async () => {
- await os.deleteConversationProject(projectId);
- runtimeStore.deleteActivityGroup(projectId);
- await invalidateConversationData();
- setSelectedProjectId(null);
- const draftRuntimeKey = rootChatDraftKeyAfterProjectDeletion(runtimeStore);
- const chatEntry = createChatHistoryEntryForDraft(draftRuntimeKey);
- window.history.replaceState(chatEntry.historyState, "", "/");
- window.dispatchEvent(
- new CustomEvent("newchat", {
- detail: { projectId: null, draftRuntimeKey: chatEntry.draftRuntimeKey }
- })
- );
- window.dispatchEvent(new Event("projectselected"));
- }, [invalidateConversationData, os, projectId, runtimeStore, setSelectedProjectId]);
+ const expectedUserId = os.auth.user?.user.id;
+ const releaseDiscoveryFence = beginAllChatRuntimeDeletionFence(runtimeStore);
+ let releaseDeletionFence: (() => void) | null = null;
+ try {
+ assertChatAccountCredential(expectedUserId);
+ const projectConversationKeys = (
+ await listAllConversations(os, { project_id: projectId })
+ ).map((conversation) => createConversationChatKey(conversation.id));
+ releaseDeletionFence = beginChatProjectRuntimeDeletionFence(
+ runtimeStore,
+ projectId,
+ projectConversationKeys
+ );
+ releaseDiscoveryFence();
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ keys: projectConversationKeys,
+ activityGroupId: projectId,
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+
+ assertChatAccountCredential(expectedUserId);
+ await os.deleteConversationProject(projectId);
+ for (const key of projectConversationKeys) runtimeStore.delete(key);
+ runtimeStore.deleteActivityGroup(projectId);
+ await invalidateConversationData();
+ setSelectedProjectId(null);
+ const draftRuntimeKey = rootChatDraftKeyAfterProjectDeletion(runtimeStore);
+ const chatEntry = createChatHistoryEntryForDraft(draftRuntimeKey);
+ window.history.replaceState(chatEntry.historyState, "", "/");
+ window.dispatchEvent(
+ new CustomEvent("newchat", {
+ detail: { projectId: null, draftRuntimeKey: chatEntry.draftRuntimeKey }
+ })
+ );
+ window.dispatchEvent(new Event("projectselected"));
+ } finally {
+ releaseDeletionFence?.();
+ releaseDiscoveryFence();
+ }
+ }, [invalidateConversationData, openai, os, projectId, runtimeStore, setSelectedProjectId]);
const handleRenameConversation = useCallback(
async (conversationId: string, newTitle: string) => {
@@ -411,16 +455,35 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
const handleDeleteConversation = useCallback(
async (conversationId: string) => {
- await os.deleteConversation(conversationId);
- runtimeStore.delete(createConversationChatKey(conversationId));
- setSelectedIds((prev) => {
- const next = new Set(prev);
- next.delete(conversationId);
- return next;
- });
- await refreshProjectPage();
+ const expectedUserId = os.auth.user?.user.id;
+ const releaseDeletionFence = beginChatRuntimeDeletionFence(
+ runtimeStore,
+ createConversationChatKey(conversationId)
+ );
+ try {
+ assertChatAccountCredential(expectedUserId);
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ keys: [createConversationChatKey(conversationId)],
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+ assertChatAccountCredential(expectedUserId);
+ await os.deleteConversation(conversationId);
+ runtimeStore.delete(createConversationChatKey(conversationId));
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ next.delete(conversationId);
+ return next;
+ });
+ await refreshProjectPage();
+ } finally {
+ releaseDeletionFence();
+ }
},
- [os, refreshProjectPage, runtimeStore]
+ [openai, os, refreshProjectPage, runtimeStore]
);
const handleToggleConversationPin = useCallback(
@@ -452,8 +515,23 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
setIsBulkDeleting(true);
setError(null);
+ const releaseDeletionFences = Array.from(selectedIds, (id) =>
+ beginChatRuntimeDeletionFence(runtimeStore, createConversationChatKey(id))
+ );
try {
- const result = await os.batchDeleteConversations(Array.from(selectedIds));
+ const expectedUserId = os.auth.user?.user.id;
+ assertChatAccountCredential(expectedUserId);
+ const idsToDelete = Array.from(selectedIds);
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ keys: idsToDelete.map((id) => createConversationChatKey(id)),
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+ assertChatAccountCredential(expectedUserId);
+ const result = await os.batchDeleteConversations(idsToDelete);
for (const item of result.data) {
if (item.deleted) runtimeStore.delete(createConversationChatKey(item.id));
}
@@ -464,9 +542,10 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
console.error("Error bulk deleting chats:", error);
setError("Failed to delete selected chats. Please try again.");
} finally {
+ for (const releaseDeletionFence of releaseDeletionFences) releaseDeletionFence();
setIsBulkDeleting(false);
}
- }, [os, refreshProjectPage, runtimeStore, selectedIds]);
+ }, [openai, os, refreshProjectPage, runtimeStore, selectedIds]);
const handleMoveSelectedConversations = useCallback(
async (targetProjectId: string | null) => {
diff --git a/frontend/src/components/RootRuntimeLayout.test.tsx b/frontend/src/components/RootRuntimeLayout.test.tsx
index a210e86b0..3cbe0f73f 100644
--- a/frontend/src/components/RootRuntimeLayout.test.tsx
+++ b/frontend/src/components/RootRuntimeLayout.test.tsx
@@ -1,7 +1,8 @@
import { describe, expect, mock, test } from "bun:test";
-import { useCallback, useEffect, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useLayoutEffect, useState, type ReactNode } from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
+import { createConversationChatKey } from "@/services/chatRuntimeStore";
import { RootRuntimeLayout } from "./RootRuntimeLayout";
function OAuthCallbackProbe({ processCallback }: { processCallback: () => void }) {
@@ -55,6 +56,48 @@ function ChatStoreProbe({ onStore }: { onStore: (store: unknown) => void }) {
return null;
}
+type AccountRunBoundary = {
+ previous?: {
+ store: {
+ getActiveRunKeys: () => readonly string[];
+ };
+ signal: AbortSignal;
+ };
+ observed?: {
+ activeRunKeys: readonly string[];
+ signalAborted: boolean;
+ };
+};
+
+function AccountRunBoundaryProbe({
+ userId,
+ boundary
+}: {
+ userId: string;
+ boundary: AccountRunBoundary;
+}) {
+ const store = useChatRuntimeStore();
+
+ useLayoutEffect(() => {
+ if (userId === "user-a") {
+ const runtimeKey = createConversationChatKey("account-boundary");
+ store.ensure(runtimeKey);
+ const run = store.beginRun(runtimeKey);
+ boundary.previous = { store, signal: run.signal };
+ return;
+ }
+
+ const previous = boundary.previous;
+ if (!previous) return;
+ boundary.observed = {
+ activeRunKeys: [...previous.store.getActiveRunKeys()],
+ signalAborted: previous.signal.aborted
+ };
+ }, [boundary, store, userId]);
+
+ return null;
+}
+
describe("RootRuntimeLayout", () => {
test("keeps the OAuth callback route mounted when success authenticates a user", () => {
const processCallback = mock(() => {});
@@ -160,6 +203,41 @@ describe("RootRuntimeLayout", () => {
act(() => renderer.unmount());
});
+ test("cancels the previous account's queue runner before new-account layout effects", () => {
+ const boundary: AccountRunBoundary = {};
+ let renderer: ReactTestRenderer;
+
+ act(() => {
+ renderer = create(
+ }
+ accountScopedUi={ }
+ />
+ );
+ });
+
+ expect(boundary.previous?.signal.aborted).toBe(false);
+
+ act(() => {
+ renderer.update(
+ }
+ accountScopedUi={ }
+ />
+ );
+ });
+
+ expect(boundary.observed).toEqual({ activeRunKeys: [], signalAborted: true });
+
+ act(() => renderer.unmount());
+ });
+
test("shares the same-account chat store when moving from home to an ordinary route", () => {
const homeStores: unknown[] = [];
const routeStores: unknown[] = [];
@@ -196,6 +274,30 @@ describe("RootRuntimeLayout", () => {
act(() => renderer.unmount());
});
+ test("shares the account chat store with account-scoped UI", () => {
+ const homeStores: unknown[] = [];
+ const accountUiStores: unknown[] = [];
+ let renderer: ReactTestRenderer;
+
+ act(() => {
+ renderer = create(
+ homeStores.push(store)} />}
+ routeContent={
}
+ accountScopedUi={ accountUiStores.push(store)} />}
+ />
+ );
+ });
+
+ expect(homeStores).toHaveLength(1);
+ expect(accountUiStores).toHaveLength(1);
+ expect(accountUiStores[0]).toBe(homeStores[0]);
+
+ act(() => renderer.unmount());
+ });
+
test("retains the same-account chat store while authenticated home is temporarily hidden", () => {
const stores: unknown[] = [];
const recordStore = mock((store: unknown) => stores.push(store));
diff --git a/frontend/src/components/RootRuntimeLayout.tsx b/frontend/src/components/RootRuntimeLayout.tsx
index 9557bdae9..936e4c470 100644
--- a/frontend/src/components/RootRuntimeLayout.tsx
+++ b/frontend/src/components/RootRuntimeLayout.tsx
@@ -32,7 +32,8 @@ function getRouteScopeKey(pathname: string, accountScopeKey: string): string {
* ordinary routed content. The OAuth callback route stays outside that provider
* so its one-shot effect cannot replay. Signup also retains its route state long
* enough to show a newly created anonymous user's Account ID. Global
- * account-scoped UI retains its previous remount behavior.
+ * account-scoped UI shares the account-keyed Chat runtime while retaining its
+ * previous account-transition remount behavior.
*/
export function RootRuntimeLayout({
userId,
@@ -53,9 +54,9 @@ export function RootRuntimeLayout({
{authenticatedHome}
{!isAuthTransitionRoute ? keyedRouteContent : null}
+ {accountScopedUi}
{isAuthTransitionRoute ? keyedRouteContent : null}
- {accountScopedUi}
>
);
}
diff --git a/frontend/src/components/VerificationModal.tsx b/frontend/src/components/VerificationModal.tsx
index 1dfc18630..610498031 100644
--- a/frontend/src/components/VerificationModal.tsx
+++ b/frontend/src/components/VerificationModal.tsx
@@ -21,11 +21,15 @@ import {
} from "@/services/agentRuntimeService";
import { resetWorkspaceModePreference } from "@/services/workspaceModePreference";
import { getBillingService } from "@/billing/billingService";
+import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
+import { beginAllChatRuntimeDeletionFence } from "@/services/chatRuntimeDeletionFence";
+import { assertChatAccountCredential } from "@/services/chatAccountCredential";
import { navigateToSafeInternalRedirect } from "@/utils/internalRedirect";
export function VerificationModal() {
const os = useOpenSecret();
const queryClient = useQueryClient();
+ const runtimeStore = useChatRuntimeStore();
const router = useRouter();
const [isOpen, setIsOpen] = useState(() => {
if (!os.auth.user) return false;
@@ -118,25 +122,42 @@ export function VerificationModal() {
const handleSignOut = async () => {
setSignOutError(null);
setIsSigningOut(true);
+ const releaseChatFence = beginAllChatRuntimeDeletionFence(runtimeStore);
let operationBlock: Awaited> | null = null;
let signedOut = false;
let nativeAuthCleared = false;
const userId = os.auth.user?.user.id;
+ try {
+ assertChatAccountCredential(userId);
+ } catch (error) {
+ console.error("Account changed before sign out:", error);
+ releaseChatFence();
+ setSignOutError("Your account changed in another window. Refresh Maple before signing out.");
+ setIsSigningOut(false);
+ return;
+ }
+
try {
operationBlock = await stopAgentRuntimeForUser(userId);
} catch (error) {
console.error("Error stopping Agent Mode:", error);
+ releaseChatFence();
setSignOutError("Maple couldn't stop Agent Mode. Please try logging out again.");
setIsSigningOut(false);
return;
}
try {
+ assertChatAccountCredential(userId);
// Credential reset is a required part of logout.
const { proxyService } = await import("@/services/proxyService");
- await proxyService.stopAndResetProxy(userId, os.deleteApiKey);
+ await proxyService.stopAndResetProxy(userId, (name) => {
+ assertChatAccountCredential(userId);
+ return os.deleteApiKey(name);
+ });
+ assertChatAccountCredential(userId);
// Do not carry this account's third-party billing JWT into the next
// authenticated session in the same WebView.
try {
@@ -147,6 +168,7 @@ export function VerificationModal() {
await clearMapleApiAuthForUser(userId);
nativeAuthCleared = true;
+ assertChatAccountCredential(userId);
await os.signOut();
signedOut = true;
resetWorkspaceModePreference();
@@ -158,6 +180,7 @@ export function VerificationModal() {
);
} finally {
if (!signedOut) {
+ releaseChatFence();
if (nativeAuthCleared) {
try {
await restoreMapleApiAuthForUser(userId);
diff --git a/frontend/src/components/settings/DeleteAccountSettings.tsx b/frontend/src/components/settings/DeleteAccountSettings.tsx
index 9d78f67d8..f5e163d00 100644
--- a/frontend/src/components/settings/DeleteAccountSettings.tsx
+++ b/frontend/src/components/settings/DeleteAccountSettings.tsx
@@ -18,6 +18,15 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
+import { useOpenAI } from "@/ai/useOpenAi";
+import { beginAllChatRuntimeDeletionFence } from "@/services/chatRuntimeDeletionFence";
+import { assertChatAccountCredential } from "@/services/chatAccountCredential";
+import {
+ cancelChatResponseForHistoryDeletion,
+ quiesceChatRuntimeRunsForHistoryDeletion,
+ settleChatRuntimeAfterHistoryCancellation
+} from "@/services/chatHistoryDeletionQuiescence";
import {
clearAgentDataForUser,
clearMapleApiAuthForUser,
@@ -29,8 +38,10 @@ import { SettingsPage, SettingsSection } from "./SettingsPage";
export function DeleteAccountSettings() {
const os = useOpenSecret();
+ const openai = useOpenAI();
const queryClient = useQueryClient();
const { billingStatus } = useBillingState();
+ const runtimeStore = useChatRuntimeStore();
const [step, setStep] = useState<"request" | "confirm">("request");
const [confirmationCode, setConfirmationCode] = useState("");
const [secret, setSecret] = useState("");
@@ -60,8 +71,12 @@ export function DeleteAccountSettings() {
setIsLoading(true);
setError(null);
try {
+ const expectedUserId = os.auth.user?.user.id;
+ assertChatAccountCredential(expectedUserId);
const generatedSecret = generateSecureSecret();
- await os.requestAccountDeletion(await hashSecret(generatedSecret));
+ const hashedSecret = await hashSecret(generatedSecret);
+ assertChatAccountCredential(expectedUserId);
+ await os.requestAccountDeletion(hashedSecret);
setSecret(generatedSecret);
setStep("confirm");
} catch (requestError) {
@@ -75,6 +90,7 @@ export function DeleteAccountSettings() {
const handleConfirmDeletion = async () => {
setIsLoading(true);
setError(null);
+ const releaseChatFence = beginAllChatRuntimeDeletionFence(runtimeStore);
let deletionConfirmed = isAccountDeleted;
let agentDataCleared = cleanupBlockRef.current !== null;
let proxyReset = false;
@@ -82,6 +98,15 @@ export function DeleteAccountSettings() {
const userId = os.auth.user?.user.id;
try {
+ assertChatAccountCredential(userId);
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+
if (!cleanupBlockRef.current) {
// Clear local Agent data before the irreversible remote deletion.
cleanupBlockRef.current = await clearAgentDataForUser(userId);
@@ -91,19 +116,28 @@ export function DeleteAccountSettings() {
// Proxy credential reset is required before remote deletion so a crash
// cannot leave a deleted account's key on disk.
const { proxyService } = await import("@/services/proxyService");
- await proxyService.stopAndResetProxy(userId, os.deleteApiKey);
+ await proxyService.stopAndResetProxy(userId, (name) => {
+ assertChatAccountCredential(userId);
+ return os.deleteApiKey(name);
+ });
proxyReset = true;
await clearMapleApiAuthForUser(userId);
nativeAuthCleared = true;
if (!deletionConfirmed) {
+ assertChatAccountCredential(userId);
await os.confirmAccountDeletion(confirmationCode, secret);
deletionConfirmed = true;
setIsAccountDeleted(true);
cleanupBlockRef.current.retainUntilNextSession();
}
+ // The remote account no longer owns any renderable client state. Clear
+ // queued messages and attachment resources before credential cleanup,
+ // which can fail independently during a cross-tab account transition.
+ runtimeStore.clearAll();
+
resetWorkspaceModePreference();
try {
@@ -122,6 +156,7 @@ export function DeleteAccountSettings() {
// If remote deletion did not happen, the authenticated user must be able
// to start a fresh Agent runtime after this attempt.
if (!deletionConfirmed) {
+ releaseChatFence();
if (nativeAuthCleared) {
try {
await restoreMapleApiAuthForUser(userId);
diff --git a/frontend/src/components/settings/HistorySettings.tsx b/frontend/src/components/settings/HistorySettings.tsx
index 1b8f75e0d..ab65a86ae 100644
--- a/frontend/src/components/settings/HistorySettings.tsx
+++ b/frontend/src/components/settings/HistorySettings.tsx
@@ -5,12 +5,23 @@ import { useOpenSecret } from "@opensecret/react";
import { Loader2, Trash2 } from "lucide-react";
import { AlertDestructive } from "@/components/AlertDestructive";
import { Button } from "@/components/ui/button";
+import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
+import { useOpenAI } from "@/ai/useOpenAi";
import { clearAgentHistoryForUser } from "@/services/agentRuntimeService";
+import {
+ cancelChatResponseForHistoryDeletion,
+ quiesceChatRuntimeRunsForHistoryDeletion,
+ settleChatRuntimeAfterHistoryCancellation
+} from "@/services/chatHistoryDeletionQuiescence";
+import { beginAllChatRuntimeDeletionFence } from "@/services/chatRuntimeDeletionFence";
+import { assertChatAccountCredential } from "@/services/chatAccountCredential";
import { SettingsPage, SettingsSection } from "./SettingsPage";
export function HistorySettings() {
const os = useOpenSecret();
+ const openai = useOpenAI();
+ const runtimeStore = useChatRuntimeStore();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isConfirming, setIsConfirming] = useState(false);
@@ -25,16 +36,29 @@ export function HistorySettings() {
useSettingsNavigationLock(isDeleting);
const handleDeleteHistory = async () => {
+ const expectedUserId = os.auth.user?.user.id;
setError(null);
setIsDeleting(true);
+ const releaseDeletionFence = beginAllChatRuntimeDeletionFence(runtimeStore);
let operationBlock: Awaited> | null = null;
try {
- const conversations = await os.listConversations({ limit: 1 });
- if (conversations.data?.length) {
- await os.deleteConversations();
- }
+ assertChatAccountCredential(expectedUserId);
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store: runtimeStore,
+ responseOwnershipClient: openai,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(openai, responseId),
+ settleCancelledRun: (key, runToken) =>
+ settleChatRuntimeAfterHistoryCancellation(runtimeStore, key, runToken)
+ });
+
+ assertChatAccountCredential(expectedUserId);
+ await os.deleteConversations();
+ // Chat deletion is now confirmed (including the already-empty case).
+ // Clear server-deleted transcripts and client-only queues even if the
+ // separate Agent history cleanup below fails.
+ runtimeStore.clearAll();
- operationBlock = await clearAgentHistoryForUser(os.auth.user?.user.id);
+ operationBlock = await clearAgentHistoryForUser(expectedUserId);
queryClient.invalidateQueries({ queryKey: ["conversations"] });
queryClient.invalidateQueries({ queryKey: ["pinnedConversations"] });
@@ -51,9 +75,12 @@ export function HistorySettings() {
window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } }));
} catch (deleteError) {
console.error("Error deleting chat history:", deleteError);
+ // Preserve unresolved response ownership so a retry can still issue the
+ // server cancellation before any destructive request.
setError("Maple could not delete all chat and task history. Please try again.");
} finally {
operationBlock?.release();
+ releaseDeletionFence();
setIsDeleting(false);
}
};
diff --git a/frontend/src/components/settings/SettingsLayout.tsx b/frontend/src/components/settings/SettingsLayout.tsx
index 1544b1aa3..e59512172 100644
--- a/frontend/src/components/settings/SettingsLayout.tsx
+++ b/frontend/src/components/settings/SettingsLayout.tsx
@@ -25,6 +25,7 @@ import { useCompactSettingsLayout } from "@/components/settings/useCompactSettin
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { usePersistentHomeNavigation } from "@/contexts/PersistentHomeNavigationContext";
+import { useChatRuntimeStore } from "@/contexts/ChatRuntimeContext";
import {
useSettingsNavigationLock,
useSettingsNavigationLockState
@@ -35,6 +36,8 @@ import {
stopAgentRuntimeForUser
} from "@/services/agentRuntimeService";
import { resetWorkspaceModePreference } from "@/services/workspaceModePreference";
+import { beginAllChatRuntimeDeletionFence } from "@/services/chatRuntimeDeletionFence";
+import { assertChatAccountCredential } from "@/services/chatAccountCredential";
import { useBillingState } from "@/state/useLocalState";
import type { TeamStatus } from "@/types/team";
import { isIOS } from "@/utils/platform";
@@ -132,6 +135,7 @@ function SettingsNavLink({
function SettingsLayoutContent() {
const os = useOpenSecret();
+ const runtimeStore = useChatRuntimeStore();
const router = useRouter();
const location = useLocation();
const queryClient = useQueryClient();
@@ -316,27 +320,47 @@ function SettingsLayoutContent() {
setSignOutError(null);
setIsSigningOut(true);
+ // Pause every client-only FIFO before awaited account cleanup can yield.
+ // If logout fails, releasing this fence does not orphan an accepted server
+ // response; committed account teardown still cancels the store itself.
+ const releaseChatFence = beginAllChatRuntimeDeletionFence(runtimeStore);
let operationBlock: Awaited> | null = null;
let signedOut = false;
let nativeAuthCleared = false;
const userId = os.auth.user?.user.id;
+ try {
+ assertChatAccountCredential(userId);
+ } catch (error) {
+ console.error("Account changed before sign out:", error);
+ releaseChatFence();
+ setSignOutError("Your account changed in another window. Refresh Maple before signing out.");
+ setIsSigningOut(false);
+ return;
+ }
+
// Never sign out while this account may still have Agent tools executing.
try {
operationBlock = await stopAgentRuntimeForUser(userId);
} catch (error) {
console.error("Error stopping Agent Mode:", error);
+ releaseChatFence();
setSignOutError("Maple could not stop Agent Mode. Please try logging out again.");
setIsSigningOut(false);
return;
}
try {
+ assertChatAccountCredential(userId);
// Credential reset is required before logout so the next account cannot
// inherit this user's local proxy key.
const { proxyService } = await import("@/services/proxyService");
- await proxyService.stopAndResetProxy(userId, os.deleteApiKey);
+ await proxyService.stopAndResetProxy(userId, (name) => {
+ assertChatAccountCredential(userId);
+ return os.deleteApiKey(name);
+ });
+ assertChatAccountCredential(userId);
try {
getBillingService().clearToken();
} catch (error) {
@@ -346,6 +370,7 @@ function SettingsLayoutContent() {
await clearMapleApiAuthForUser(userId);
nativeAuthCleared = true;
+ assertChatAccountCredential(userId);
await os.signOut();
signedOut = true;
resetWorkspaceModePreference();
@@ -363,6 +388,7 @@ function SettingsLayoutContent() {
);
} finally {
if (!signedOut) {
+ releaseChatFence();
if (nativeAuthCleared) {
try {
await restoreMapleApiAuthForUser(userId);
diff --git a/frontend/src/services/chatHistoryDeletionQuiescence.test.ts b/frontend/src/services/chatHistoryDeletionQuiescence.test.ts
new file mode 100644
index 000000000..37164a61a
--- /dev/null
+++ b/frontend/src/services/chatHistoryDeletionQuiescence.test.ts
@@ -0,0 +1,360 @@
+import { describe, expect, test } from "bun:test";
+import {
+ cancelChatResponseForHistoryDeletion,
+ quiesceChatRuntimeRunsForHistoryDeletion,
+ settleChatRuntimeAfterHistoryCancellation
+} from "./chatHistoryDeletionQuiescence";
+import { registerChatCurrentTurn } from "./chatCurrentTurnRegistry";
+import { registerUnresolvedChatResponseMessage } from "./chatUnresolvedResponseOwnership";
+import {
+ ChatRuntimeStore,
+ createConversationChatKey,
+ type ChatRuntimeKey
+} from "./chatRuntimeStore";
+
+function fakeStore() {
+ const key = "conversation:active" as ChatRuntimeKey;
+ let snapshot: { runToken: number | null; currentResponseId: string | undefined } | undefined = {
+ runToken: 1,
+ currentResponseId: undefined
+ };
+ return {
+ key,
+ setSnapshot(next: typeof snapshot) {
+ snapshot = next;
+ },
+ getActiveRunKeys: () => (snapshot?.runToken === null || !snapshot ? [] : [key]),
+ get: () => snapshot,
+ setCurrentResponseId: (_key: ChatRuntimeKey, runToken: number, responseId: string) => {
+ if (!snapshot || snapshot.runToken !== runToken) return false;
+ snapshot = { ...snapshot, currentResponseId: responseId };
+ return true;
+ },
+ cancelRun: () => {
+ snapshot = undefined;
+ }
+ };
+}
+
+describe("chat history deletion quiescence", () => {
+ test("waits for conversation creation, then cancels the surfaced response", async () => {
+ const store = fakeStore();
+ const cancelled: string[] = [];
+ setTimeout(() => {
+ store.setSnapshot({ runToken: 1, currentResponseId: "response-late" });
+ }, 5);
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 200,
+ cancelResponse: async (responseId) => {
+ cancelled.push(responseId);
+ store.setSnapshot(undefined);
+ }
+ });
+
+ expect(cancelled).toEqual(["response-late"]);
+ });
+
+ test("fails closed instead of deleting while a create request remains unresolved", async () => {
+ const store = fakeStore();
+
+ await expect(
+ quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 1,
+ cancelResponse: async () => undefined
+ })
+ ).rejects.toThrow("Timed out waiting for active chat requests to stop");
+ });
+
+ test("allows a naturally completed run to settle after cancellation loses the race", async () => {
+ const store = fakeStore();
+ store.setSnapshot({ runToken: 1, currentResponseId: "response-completing" });
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 200,
+ cancelResponse: async () => {
+ setTimeout(() => store.setSnapshot(undefined), 5);
+ throw new Error("response is already terminal");
+ }
+ });
+
+ expect(store.getActiveRunKeys()).toEqual([]);
+ });
+
+ test("still fails closed when cancellation rejects and the run stays active", async () => {
+ const store = fakeStore();
+ store.setSnapshot({ runToken: 1, currentResponseId: "response-stuck" });
+
+ await expect(
+ quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 1,
+ cancelResponse: async () => {
+ throw new Error("cancel failed");
+ }
+ })
+ ).rejects.toThrow("Timed out waiting for active chat requests to stop");
+ });
+
+ test("retries a rejected cancellation while response ownership remains", async () => {
+ const store = fakeStore();
+ store.setSnapshot({ runToken: 1, currentResponseId: "response-retry" });
+ let attempts = 0;
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 750,
+ cancelResponse: async () => {
+ attempts += 1;
+ if (attempts === 1) throw new Error("transient cancellation error");
+ store.setSnapshot(undefined);
+ }
+ });
+
+ expect(attempts).toBe(2);
+ });
+
+ test("bounds a cancellation request that never settles", async () => {
+ const store = fakeStore();
+ store.setSnapshot({ runToken: 1, currentResponseId: "response-hung" });
+
+ await expect(
+ quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 5,
+ cancelResponse: () => new Promise(() => undefined)
+ })
+ ).rejects.toThrow("Timed out waiting for active chat requests to stop");
+ });
+
+ test("quiesces only exact target keys and leaves unrelated runs active", async () => {
+ const target = "conversation:target" as ChatRuntimeKey;
+ const unrelated = "conversation:unrelated" as ChatRuntimeKey;
+ const snapshots = new Map([
+ [target, { runToken: 1, currentResponseId: "response-target" }],
+ [unrelated, { runToken: 2, currentResponseId: "response-unrelated" }]
+ ]);
+ const store = {
+ getActiveRunKeys: () => Array.from(snapshots.keys()),
+ get: (key: ChatRuntimeKey) => snapshots.get(key),
+ resolveKey: (key: ChatRuntimeKey) => key,
+ cancelRun: (key: ChatRuntimeKey) => snapshots.delete(key)
+ };
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ keys: [target],
+ timeoutMs: 100,
+ cancelResponse: async (responseId) => {
+ expect(responseId).toBe("response-target");
+ snapshots.delete(target);
+ }
+ });
+
+ expect(store.getActiveRunKeys()).toEqual([unrelated]);
+ });
+
+ test("includes active draft runs owned by a deleted project group", async () => {
+ const draft = "draft:project" as ChatRuntimeKey;
+ const unrelated = "draft:other" as ChatRuntimeKey;
+ const snapshots = new Map([
+ [draft, { runToken: 1, currentResponseId: "response-project" }],
+ [unrelated, { runToken: 2, currentResponseId: "response-other" }]
+ ]);
+ const store = {
+ getActiveRunKeys: () => Array.from(snapshots.keys()),
+ get: (key: ChatRuntimeKey) => snapshots.get(key),
+ getActiveRunGroupId: (key: ChatRuntimeKey) =>
+ key === draft ? "project-delete" : "project-other",
+ cancelRun: (key: ChatRuntimeKey) => snapshots.delete(key)
+ };
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ activityGroupId: "project-delete",
+ timeoutMs: 100,
+ cancelResponse: async () => {
+ snapshots.delete(draft);
+ }
+ });
+
+ expect(store.getActiveRunKeys()).toEqual([unrelated]);
+ });
+
+ test("settles an offscreen detached run after durable server cancellation", async () => {
+ const store = fakeStore();
+ store.setSnapshot({ runToken: 7, currentResponseId: "response-detached" });
+ const cancelled: string[] = [];
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 100,
+ cancelResponse: async (responseId) => {
+ cancelled.push(responseId);
+ }
+ });
+
+ expect(cancelled).toEqual(["response-detached"]);
+ expect(store.getActiveRunKeys()).toEqual([]);
+ });
+
+ test("recovers an offscreen response by the exact current optimistic message before deletion", async () => {
+ const store = fakeStore();
+ registerUnresolvedChatResponseMessage(store, 1, "message-current");
+ const retrieved: Array<{ messageId: string; conversationId: string }> = [];
+ const cancelled: string[] = [];
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 100,
+ responseOwnershipClient: {
+ conversations: {
+ items: {
+ retrieve: async (messageId, query) => {
+ retrieved.push({ messageId, conversationId: query.conversation_id });
+ return {
+ id: "message-current",
+ role: "user",
+ response_id: "response-current"
+ };
+ }
+ }
+ }
+ },
+ cancelResponse: async (responseId) => {
+ cancelled.push(responseId);
+ }
+ });
+
+ expect(retrieved).toEqual([{ messageId: "message-current", conversationId: "active" }]);
+ expect(cancelled).toEqual(["response-current"]);
+ expect(store.getActiveRunKeys()).toEqual([]);
+ });
+
+ test("settles an offscreen run that became terminal before cancellation", async () => {
+ const store = fakeStore();
+ store.setSnapshot({ runToken: 8, currentResponseId: "response-completed" });
+ let cancelAttempts = 0;
+ let retrieveAttempts = 0;
+ const client = {
+ responses: {
+ cancel: async () => {
+ cancelAttempts += 1;
+ throw Object.assign(new Error("response is already terminal"), { status: 400 });
+ },
+ retrieve: async () => {
+ retrieveAttempts += 1;
+ return { status: "completed" };
+ }
+ }
+ };
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 100,
+ cancelResponse: (responseId) => cancelChatResponseForHistoryDeletion(client, responseId)
+ });
+
+ expect(cancelAttempts).toBe(1);
+ expect(retrieveAttempts).toBe(1);
+ expect(store.getActiveRunKeys()).toEqual([]);
+ });
+
+ test("does not accept a terminal retrieval after an ambiguous cancel 503", async () => {
+ let retrieveAttempts = 0;
+ const cancellationError = Object.assign(new Error("cancel quiescence timed out"), {
+ status: 503
+ });
+ const client = {
+ responses: {
+ cancel: async () => {
+ throw cancellationError;
+ },
+ retrieve: async () => {
+ retrieveAttempts += 1;
+ return { status: "cancelled" };
+ }
+ }
+ };
+
+ await expect(
+ cancelChatResponseForHistoryDeletion(client, "response-not-quiescent")
+ ).rejects.toBe(cancellationError);
+ expect(retrieveAttempts).toBe(0);
+ });
+
+ test("locally settles preparation that has not started a server request", async () => {
+ const store = fakeStore();
+ let restored = false;
+ registerChatCurrentTurn(store, 1, {
+ responseRequestStarted: () => false,
+ serverRequestInFlight: () => false,
+ restoreBeforeRequest: () => {
+ restored = true;
+ return true;
+ }
+ });
+
+ await quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 100,
+ cancelResponse: async () => {
+ throw new Error("no server response should be cancelled");
+ }
+ });
+
+ expect(restored).toBe(true);
+ expect(store.getActiveRunKeys()).toEqual([]);
+ });
+
+ test("does not locally settle while conversation creation is in flight", async () => {
+ const store = fakeStore();
+ let restored = false;
+ registerChatCurrentTurn(store, 1, {
+ responseRequestStarted: () => false,
+ serverRequestInFlight: () => true,
+ restoreBeforeRequest: () => {
+ restored = true;
+ return true;
+ }
+ });
+
+ await expect(
+ quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ timeoutMs: 2,
+ cancelResponse: async () => undefined
+ })
+ ).rejects.toThrow("Timed out waiting for active chat requests to stop");
+
+ expect(restored).toBe(false);
+ expect(store.getActiveRunKeys()).toHaveLength(1);
+ });
+
+ test("marks partial items incomplete when cancellation succeeds but deletion later fails", () => {
+ const store = new ChatRuntimeStore({
+ createComposer: () => ({ input: "" })
+ });
+ const key = createConversationChatKey("cancelled-before-delete");
+ store.select(key);
+ store.update(key, (snapshot) => ({
+ ...snapshot,
+ messages: [
+ { id: "user", status: "completed" },
+ { id: "assistant", status: "streaming" }
+ ]
+ }));
+ const run = store.beginRun(key);
+ store.setCurrentResponseId(key, run.token, "response-cancelled");
+
+ expect(settleChatRuntimeAfterHistoryCancellation(store, key, run.token)).toBe(true);
+ expect(store.get(key)).toMatchObject({ isGenerating: false, currentResponseId: undefined });
+ expect(store.get(key)?.messages).toEqual([
+ { id: "user", status: "completed" },
+ { id: "assistant", status: "incomplete" }
+ ]);
+ });
+});
diff --git a/frontend/src/services/chatHistoryDeletionQuiescence.ts b/frontend/src/services/chatHistoryDeletionQuiescence.ts
new file mode 100644
index 000000000..fbab121d3
--- /dev/null
+++ b/frontend/src/services/chatHistoryDeletionQuiescence.ts
@@ -0,0 +1,279 @@
+import {
+ registeredChatTurnCanSettleLocallyForDeletion,
+ restoreRegisteredChatTurnBeforeRequest
+} from "./chatCurrentTurnRegistry";
+import { conversationIdFromChatRuntimeKey } from "./chatRuntimeNavigation";
+import {
+ classifyChatResponseReconciliation,
+ responseIdForChatMessage
+} from "./chatResponseReconciliation";
+import { isChatResponseCancellationAlreadyTerminalError } from "./chatResponseErrors";
+import type { ChatRuntimeKey, ChatRuntimeStore } from "./chatRuntimeStore";
+import {
+ clearUnresolvedChatResponseMessage,
+ getUnresolvedChatResponseMessage
+} from "./chatUnresolvedResponseOwnership";
+
+type ActiveChatRuntimeLookup = object & {
+ getActiveRunKeys: () => readonly ChatRuntimeKey[];
+ resolveKey?: (key: ChatRuntimeKey) => ChatRuntimeKey;
+ getActiveRunGroupId?: (key: ChatRuntimeKey) => string | null | undefined;
+ get: (
+ key: ChatRuntimeKey
+ ) => Readonly<{ runToken: number | null; currentResponseId: string | undefined }> | undefined;
+ setCurrentResponseId?: (key: ChatRuntimeKey, runToken: number, responseId: string) => boolean;
+ cancelRun: (key: ChatRuntimeKey, runToken: number) => unknown;
+};
+
+const CHAT_HISTORY_CANCEL_REQUEST_TIMEOUT_MS = 5000;
+const CHAT_HISTORY_CANCEL_RETRY_INTERVAL_MS = 250;
+
+type ChatResponseCancellationClient = {
+ responses: {
+ cancel: (responseId: string, options?: { timeout?: number }) => PromiseLike;
+ retrieve: (
+ responseId: string,
+ query?: undefined,
+ options?: { timeout?: number }
+ ) => PromiseLike<{ status?: string | null }>;
+ };
+};
+
+type ChatResponseOwnershipClient = {
+ conversations: {
+ items: {
+ retrieve: (
+ messageId: string,
+ query: { conversation_id: string }
+ ) => PromiseLike<{ id?: string; role?: string; response_id?: unknown }>;
+ };
+ };
+};
+
+async function recoverChatResponseOwnershipForHistoryDeletion(
+ client: ChatResponseOwnershipClient,
+ store: ActiveChatRuntimeLookup,
+ key: ChatRuntimeKey,
+ runToken: number
+): Promise {
+ const messageId = getUnresolvedChatResponseMessage(store, runToken);
+ const conversationId = conversationIdFromChatRuntimeKey(store.resolveKey?.(key) ?? key);
+ if (!messageId || !conversationId || !store.setCurrentResponseId) return undefined;
+
+ try {
+ const item = await client.conversations.items.retrieve(messageId, {
+ conversation_id: conversationId
+ });
+ const responseId = responseIdForChatMessage(messageId, [item]);
+ if (!responseId || !store.setCurrentResponseId(key, runToken, responseId)) return undefined;
+ clearUnresolvedChatResponseMessage(store, runToken, messageId);
+ return responseId;
+ } catch (error) {
+ if ((error as { status?: unknown })?.status === 404) return undefined;
+ throw error;
+ }
+}
+
+export async function cancelChatResponseForHistoryDeletion(
+ client: ChatResponseCancellationClient,
+ responseId: string
+): Promise {
+ try {
+ return await client.responses.cancel(responseId, {
+ timeout: CHAT_HISTORY_CANCEL_REQUEST_TIMEOUT_MS
+ });
+ } catch (cancellationError) {
+ if (!isChatResponseCancellationAlreadyTerminalError(cancellationError)) {
+ throw cancellationError;
+ }
+ // A detached response can finish between discovery and cancellation. Its
+ // cancel endpoint's explicit 400 certifies that execution is quiescent even
+ // though deletion still needs to confirm the exact durable terminal state.
+ try {
+ const response = await client.responses.retrieve(responseId, undefined, {
+ timeout: CHAT_HISTORY_CANCEL_REQUEST_TIMEOUT_MS
+ });
+ if (classifyChatResponseReconciliation(response.status) !== "pending") {
+ return response;
+ }
+ } catch {
+ // Preserve the cancellation failure so the bounded quiescence loop can
+ // retry. A failed status check is never permission to delete.
+ }
+ throw cancellationError;
+ }
+}
+
+/** Keep a failed destructive request renderable after its response was already cancelled. */
+export function settleChatRuntimeAfterHistoryCancellation(
+ store: ChatRuntimeStore,
+ key: ChatRuntimeKey,
+ runToken: number
+): boolean {
+ const cancelled = store.cancelRun(key, runToken);
+ if (!cancelled) return false;
+ store.update(key, (snapshot) => ({
+ ...snapshot,
+ messages: snapshot.messages.map((message) => {
+ if (!message || typeof message !== "object" || !("status" in message)) return message;
+ const status = (message as { status?: unknown }).status;
+ if (status !== "in_progress" && status !== "streaming" && status !== "searching") {
+ return message;
+ }
+ return { ...message, status: "incomplete" } as TMessage;
+ })
+ }));
+ return true;
+}
+
+function deletionTimeoutError(): Error {
+ return new Error("Timed out waiting for active chat requests to stop");
+}
+
+async function settleCancellationRequestsBeforeDeadline(
+ requests: readonly Promise[],
+ deadline: number
+): Promise[]> {
+ if (requests.length === 0) return [];
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) throw deletionTimeoutError();
+
+ let timeout: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ Promise.allSettled(requests),
+ new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(deletionTimeoutError()), remainingMs);
+ })
+ ]);
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
+ }
+}
+
+export async function quiesceChatRuntimeRunsForHistoryDeletion({
+ store,
+ cancelResponse,
+ responseOwnershipClient,
+ settleCancelledRun,
+ keys,
+ activityGroupId,
+ timeoutMs = 30_000
+}: {
+ store: ActiveChatRuntimeLookup;
+ cancelResponse: (responseId: string) => Promise;
+ responseOwnershipClient?: ChatResponseOwnershipClient;
+ settleCancelledRun?: (key: ChatRuntimeKey, runToken: number) => unknown;
+ keys?: readonly ChatRuntimeKey[];
+ activityGroupId?: string;
+ timeoutMs?: number;
+}): Promise {
+ const deadline = Date.now() + timeoutMs;
+ const cancellationAccepted = new Set();
+ const cancellationLastAttemptMs = new Map();
+ const ownershipLastAttemptMs = new Map();
+ const resolveKey = (key: ChatRuntimeKey) => store.resolveKey?.(key) ?? key;
+ const targetActiveRunKeys = () => {
+ const activeKeys = store.getActiveRunKeys();
+ if (!keys && !activityGroupId) return activeKeys;
+ return activeKeys.filter(
+ (activeKey) =>
+ keys?.some((key) => resolveKey(key) === resolveKey(activeKey)) ||
+ (activityGroupId !== undefined &&
+ store.getActiveRunGroupId?.(activeKey) === activityGroupId)
+ );
+ };
+
+ while (targetActiveRunKeys().length > 0) {
+ const responseIds: string[] = [];
+ const ownershipRequests: Promise[] = [];
+ const now = Date.now();
+ for (const key of targetActiveRunKeys()) {
+ const snapshot = store.get(key);
+ if (!snapshot || snapshot.runToken === null) continue;
+ const canSettleLocally = registeredChatTurnCanSettleLocallyForDeletion(
+ store,
+ snapshot.runToken
+ );
+ const restoredLocally =
+ canSettleLocally &&
+ restoreRegisteredChatTurnBeforeRequest(
+ store,
+ snapshot.runToken,
+ "Sending paused while chat history is being deleted."
+ );
+ if (restoredLocally) {
+ store.cancelRun(key, snapshot.runToken);
+ clearUnresolvedChatResponseMessage(store, snapshot.runToken);
+ continue;
+ }
+ if (!snapshot.currentResponseId && responseOwnershipClient) {
+ const ownershipKey = `${resolveKey(key)}:${snapshot.runToken}`;
+ const lastOwnershipAttempt = ownershipLastAttemptMs.get(ownershipKey);
+ if (
+ lastOwnershipAttempt === undefined ||
+ now - lastOwnershipAttempt >= CHAT_HISTORY_CANCEL_RETRY_INTERVAL_MS
+ ) {
+ ownershipLastAttemptMs.set(ownershipKey, now);
+ ownershipRequests.push(
+ recoverChatResponseOwnershipForHistoryDeletion(
+ responseOwnershipClient,
+ store,
+ key,
+ snapshot.runToken
+ )
+ );
+ }
+ }
+ const lastAttempt = snapshot.currentResponseId
+ ? cancellationLastAttemptMs.get(snapshot.currentResponseId)
+ : undefined;
+ if (
+ snapshot.currentResponseId &&
+ !cancellationAccepted.has(snapshot.currentResponseId) &&
+ (lastAttempt === undefined || now - lastAttempt >= CHAT_HISTORY_CANCEL_RETRY_INTERVAL_MS)
+ ) {
+ cancellationLastAttemptMs.set(snapshot.currentResponseId, now);
+ responseIds.push(snapshot.currentResponseId);
+ }
+ }
+
+ await settleCancellationRequestsBeforeDeadline(ownershipRequests, deadline);
+
+ // Cancellation can lose a race with natural completion: the endpoint then
+ // rejects because the response is already terminal even though its SSE
+ // terminal frame is about to settle the local run. Treat cancellation as a
+ // request, then keep the deletion fence closed until every run actually
+ // settles (or the bounded timeout fails closed).
+ const cancellationResults = await settleCancellationRequestsBeforeDeadline(
+ responseIds.map(cancelResponse),
+ deadline
+ );
+ cancellationResults.forEach((result, index) => {
+ if (result.status !== "fulfilled") return;
+ const responseId = responseIds[index];
+ cancellationAccepted.add(responseId);
+ // The cancellation endpoint commits terminal response ownership before
+ // it resolves. An offscreen detached run has no SSE listener left to
+ // settle its client token, so retire that exact response locally here.
+ for (const key of targetActiveRunKeys()) {
+ const snapshot = store.get(key);
+ if (
+ snapshot?.runToken !== null &&
+ snapshot?.runToken !== undefined &&
+ snapshot.currentResponseId === responseId
+ ) {
+ if (settleCancelledRun) settleCancelledRun(key, snapshot.runToken);
+ else store.cancelRun(key, snapshot.runToken);
+ }
+ }
+ });
+ if (targetActiveRunKeys().length === 0) return;
+ if (Date.now() >= deadline) {
+ throw deletionTimeoutError();
+ }
+ // A conversation-create request has no response ID yet. Let it settle and
+ // surface its ID (if any), then cancel it before issuing delete-all.
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, deadline - Date.now())));
+ }
+}
From 87e512dd0c9af98ada54088df148f0b551b2a342 Mon Sep 17 00:00:00 2001
From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Date: Thu, 3 Sep 2026 18:27:48 +0000
Subject: [PATCH 5/5] chore(sdk): pin patched fast-uri resolution
---
sdk/bun.lock | 5 ++++-
sdk/package.json | 3 +++
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/sdk/bun.lock b/sdk/bun.lock
index 6e622fe90..5e1473b94 100644
--- a/sdk/bun.lock
+++ b/sdk/bun.lock
@@ -36,6 +36,9 @@
},
},
},
+ "overrides": {
+ "fast-uri": "3.1.6",
+ },
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -447,7 +450,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
- "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
+ "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
diff --git a/sdk/package.json b/sdk/package.json
index 0d859e4e8..af05f8e97 100644
--- a/sdk/package.json
+++ b/sdk/package.json
@@ -27,6 +27,9 @@
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
+ "overrides": {
+ "fast-uri": "3.1.6"
+ },
"dependencies": {
"@peculiar/x509": "1.14.3",
"@stablelib/base64": "2.0.1",