diff --git a/frontend/src/ai/OpenAIContext.tsx b/frontend/src/ai/OpenAIContext.tsx
index 3cead47cb..9d6f1b0a8 100644
--- a/frontend/src/ai/OpenAIContext.tsx
+++ b/frontend/src/ai/OpenAIContext.tsx
@@ -1,5 +1,6 @@
import OpenAI from "openai";
import { useOpenSecret } from "@opensecret/react";
+import { createAccountBoundChatFetch } from "@/services/chatAccountCredential";
import { OpenAIContext } from "./OpenAIContextDef";
export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => {
@@ -8,7 +9,7 @@ export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => {
throw new Error("VITE_OPEN_SECRET_API_URL must be set");
}
- const { aiCustomFetch } = useOpenSecret();
+ const { aiCustomFetch, auth } = useOpenSecret();
const access_token = window.localStorage.getItem("access_token");
// If we're not logged in we can't set up openai
@@ -24,7 +25,11 @@ export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => {
defaultHeaders: {
"Accept-Encoding": "identity"
},
- fetch: aiCustomFetch,
+ fetch: createAccountBoundChatFetch({
+ expectedUserId: auth.user?.user.id,
+ getAccessToken: () => window.localStorage.getItem("access_token"),
+ fetch: aiCustomFetch
+ }),
maxRetries: 0 // Disable automatic retries
});
diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx
index 37fddd6cc..fb3e58294 100644
--- a/frontend/src/components/AgentMode.tsx
+++ b/frontend/src/components/AgentMode.tsx
@@ -59,6 +59,11 @@ import {
ChatUserTurn
} from "@/components/chat/ChatTurn";
import { ChatCopyButton } from "@/components/chat/ChatCopyButton";
+import {
+ DiscardQueuedMessageEditButton,
+ QUEUED_MESSAGE_EDIT_PLACEHOLDER,
+ QueuedComposerMessages
+} from "@/components/chat/QueuedComposerMessages";
import {
continueChatComposerList,
continueChatComposerListBeforeInput
@@ -5062,55 +5067,16 @@ function AgentComposer({
{isExpanded ? : }
) : null}
- {queuedMessages.length > 0 ? (
-
- {queuedMessages.map((item) => (
-
-
- {item.text || `${item.attachments?.length ?? 0} image attachment(s)`}
-
- {onCancelQueuedMessage ? (
-
onCancelQueuedMessage(item.queueId)}
- aria-label="Remove queued message"
- >
-
-
- ) : null}
- {onEditQueuedMessage ? (
-
onEditQueuedMessage(item.queueId)}
- aria-label="Edit queued message"
- >
-
-
- ) : null}
- {onSteerQueuedMessage ? (
-
onSteerQueuedMessage(item.queueId)}
- disabled={isSendDisabled}
- title="Send into the current turn"
- aria-label="Send queued message into the current turn"
- >
-
-
- ) : null}
-
- ))}
-
- ) : null}
+ `${item.attachments?.length ?? 0} image attachment(s)`}
+ onRemove={onCancelQueuedMessage}
+ onEdit={onEditQueuedMessage}
+ onSendNow={onSteerQueuedMessage}
+ sendNowDisabled={isSendDisabled}
+ />
{!editingQueueId && draftImages.length > 0 ? (
{draftImages.map((image, index) => (
@@ -5149,9 +5115,7 @@ function AgentComposer({
onPaste={onImagePaste}
disabled={isSendDisabled}
placeholder={
- editingQueueId
- ? "Edit the queued message, then send to keep its place..."
- : "Ask Maple to work in this folder..."
+ editingQueueId ? QUEUED_MESSAGE_EDIT_PLACEHOLDER : "Ask Maple to work in this folder..."
}
className={cn(
CHAT_COMPOSER_TEXTAREA_CLASS,
@@ -5234,15 +5198,7 @@ function AgentComposer({
{editingQueueId && onDiscardQueuedMessageEdit ? (
-
- Discard
-
+
) : null}
{agentComposerShowsStop(isSending) ? (
{
+ 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/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx
index fb763fde7..2cb30d0ef 100644
--- a/frontend/src/components/UnifiedChat.tsx
+++ b/frontend/src/components/UnifiedChat.tsx
@@ -6,7 +6,8 @@ import {
useCallback,
memo,
useMemo,
- useId
+ useId,
+ useSyncExternalStore
} from "react";
import { flushSync } from "react-dom";
import {
@@ -60,6 +61,11 @@ import {
ChatDesktopConversationHeader,
ChatUserTurn
} from "@/components/chat/ChatTurn";
+import {
+ DiscardQueuedMessageEditButton,
+ QUEUED_MESSAGE_EDIT_PLACEHOLDER,
+ QueuedComposerMessages
+} from "@/components/chat/QueuedComposerMessages";
import { ChatCopyButton } from "@/components/chat/ChatCopyButton";
import { ToolActivityCard } from "@/components/ToolActivityCard";
import {
@@ -161,23 +167,69 @@ import {
cleanupRecordingForTeardown,
isRecordingOwnershipCurrent
} from "@/services/chatRecordingNavigation";
-import {
- canAdoptAttachmentDestination,
- mutateAttachmentComposerWhenIdle,
- planRestoredImageUrls
-} from "@/services/chatAttachmentOwnership";
+import { canAdoptAttachmentDestination } from "@/services/chatAttachmentOwnership";
import {
classifyChatStreamEof,
createChatStreamDeltaCoalescer,
flushRegisteredChatStreamDeltas,
isTerminalChatStreamErrorEvent,
registerChatStreamDeltaCoalescer,
- removeOwnedChatStreamAttemptItems,
unregisterChatStreamDeltaCoalescer,
type ChatStreamTerminalState
} from "@/services/chatStreamDeltaCoalescer";
-import { recoverFailedSendAfterDestinationAdoption } from "@/services/chatSendFailureRecovery";
-import { isImageDescriptionUnavailableError } from "@/services/chatResponseErrors";
+import {
+ isChatRequestDefinitelyNotDispatchedError,
+ isChatResponseCancellationAlreadyTerminalError,
+ isChatResponseDefinitelyRejectedError,
+ isImageDescriptionUnavailableError
+} from "@/services/chatResponseErrors";
+import { isChatAccountCredentialMismatchError } from "@/services/chatAccountCredential";
+import { chatCursorAfterSendFailure } from "@/services/chatSendFailureRecovery";
+import { normalizeChatPollingPage } from "@/services/chatPollingPage";
+import {
+ classifyChatResponseReconciliation,
+ responseIdForChatMessage
+} from "@/services/chatResponseReconciliation";
+import {
+ clearUnresolvedChatResponseMessage,
+ getUnresolvedChatResponseMessage,
+ registerUnresolvedChatResponseMessage
+} from "@/services/chatUnresolvedResponseOwnership";
+import {
+ chatAccountQueueUsage,
+ selectChatImageFilesForRetention
+} from "@/services/chatAccountQueueBudget";
+import { isChatRuntimeDeletionPending } from "@/services/chatRuntimeDeletionFence";
+import {
+ clearChatRunQueueHalt,
+ isChatRunQueueHaltRequested,
+ requestChatRunQueueHalt
+} from "@/services/chatRunQueueHalt";
+import { chatStoppingRuntimeRegistryFor } from "@/services/chatStoppingRuntimeRegistry";
+import {
+ registerChatCurrentTurn,
+ restoreRegisteredChatTurnBeforeRequest
+} from "@/services/chatCurrentTurnRegistry";
+import {
+ beginChatQueuedMessageEdit,
+ cancelChatQueuedMessage,
+ chatComposerObjectUrls,
+ chatQueuedTextByteLength,
+ discardChatQueuedMessageEdit,
+ MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES,
+ MAX_CHAT_ACCOUNT_RETAINED_IMAGES,
+ mergeChatComposerDraftsForRekey,
+ recoverDetachedChatComposerDraft,
+ takeNextChatQueuedMessage,
+ type ChatQueuedMessage,
+ type ChatQueuedMessageMetadata
+} from "@/services/chatComposerQueue";
+import {
+ canSubmitChatComposer,
+ chatComposerWithInputOverride,
+ chatComposerShowsStop,
+ planChatComposerSubmission
+} from "@/services/chatComposerSend";
import {
chatToolCallStatus,
chatToolOutputStatus,
@@ -195,6 +247,12 @@ import { toolKindFromName } from "@/services/toolPresentation";
const CHAT_ALERT_CLASS = "absolute top-16 left-1/2 z-50 w-full max-w-2xl -translate-x-1/2 px-4";
const STREAM_EVENT_DEBUG_STORAGE_KEY = "maple:sse-debug";
+const CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE =
+ "Chat drafts and queued attachments can use up to 256 MiB across your account";
+const CHAT_ACCOUNT_IMAGE_LIMIT_MESSAGE = `Chat drafts and queued messages can retain up to ${MAX_CHAT_ACCOUNT_RETAINED_IMAGES} images across your account`;
+const CHAT_MESSAGE_IMAGE_LIMIT_MESSAGE = "You can attach up to 10 images to a message";
+const CHAT_STOP_WAITING_MESSAGE = "Stopping as soon as the response is ready…";
+const CHAT_STOP_REQUEST_TIMEOUT_MS = 5000;
function isStreamEventDebugLoggingEnabled(): boolean {
if (!import.meta.env.DEV || typeof window === "undefined") return false;
@@ -271,6 +329,75 @@ type Message =
| ToolOutputItem
| ReasoningItem;
+function queuedChatMessageText(item: ChatQueuedMessage): string {
+ return item.documentText + (item.documentText && item.text ? `\n\n${item.text}` : item.text);
+}
+
+function queuedChatMessageContent(
+ item: ChatQueuedMessage,
+ imageUrlForFile: (file: File) => string | undefined
+): (InputTextContent | InputImageContent)[] {
+ const content: (InputTextContent | InputImageContent)[] = [];
+ const text = queuedChatMessageText(item);
+ if (text) content.push({ type: "input_text", text });
+ for (const file of item.draftImages) {
+ const imageUrl = imageUrlForFile(file);
+ if (!imageUrl) continue;
+ content.push({
+ type: "input_image",
+ image_url: imageUrl,
+ detail: "auto",
+ file_id: null
+ });
+ }
+ return content;
+}
+
+function promotedChatUserMessage(
+ item: ChatQueuedMessage,
+ content = queuedChatMessageContent(item, (file) => item.imageUrls.get(file))
+): Message {
+ return {
+ id: item.messageId,
+ type: "message",
+ role: "user",
+ content,
+ status: "completed"
+ } as unknown as Message;
+}
+
+function queuedChatMessageFallbackLabel(item: ChatQueuedMessage): string {
+ if (item.documentName) return item.documentName;
+ const imageCount = item.draftImages.length;
+ return imageCount === 1 ? "1 image" : imageCount > 1 ? `${imageCount} images` : "Queued message";
+}
+
+function chatTranscriptObjectUrls(messages: readonly Message[]): string[] {
+ const urls = new Set();
+ for (const message of messages) {
+ if (message.type !== "message" || !Array.isArray(message.content)) continue;
+ for (const content of message.content) {
+ if (
+ content.type === "input_image" &&
+ typeof content.image_url === "string" &&
+ content.image_url.startsWith("blob:")
+ ) {
+ urls.add(content.image_url);
+ }
+ }
+ }
+ return Array.from(urls);
+}
+
+function revokeQueuedChatMessageObjectUrls(
+ item: ChatQueuedMessage,
+ retainedObjectUrls: ReadonlySet = new Set()
+): void {
+ for (const url of new Set(item.imageUrls.values())) {
+ if (!retainedObjectUrls.has(url)) URL.revokeObjectURL(url);
+ }
+}
+
// Helper function to merge messages while ensuring uniqueness by ID
// This prevents duplicate key warnings in React by deduplicating messages
function mergeMessagesById(existingMessages: Message[], newMessages: Message[]): Message[] {
@@ -688,7 +815,7 @@ function summarizeStreamEventForLog(eventType: string, event: unknown): Record
): Message[] {
const updatedMessages = messages
@@ -1412,6 +1539,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
const runtimeStore = useChatRuntimeStore();
const runtimeInstanceId = useId();
const visibleChatOwner = useRef({}).current;
+ const responseReconciliationsInFlightRef = useRef(new Set());
const [initialRuntimeSelection] = useState(() => {
const params = new URLSearchParams(window.location.search);
@@ -1515,18 +1643,17 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
[runtimeStore]
);
- const updateIdleAttachmentComposerForKey = useCallback(
+ const updateAttachmentComposerForKey = useCallback(
(key: ChatRuntimeKey, updater: (composer: ChatComposerState) => ChatComposerState) => {
- const startSnapshot = runtimeStore.get(key);
- if (!startSnapshot || startSnapshot.isGenerating) return false;
-
- const result = mutateAttachmentComposerWhenIdle(startSnapshot, updater);
- if (!result.didMutate) return false;
- runtimeStore.update(key, (snapshot) => ({
- ...snapshot,
- composer: result.composer
- }));
- return true;
+ if (!runtimeStore.get(key)) return false;
+ let didMutate = false;
+ runtimeStore.update(key, (snapshot) => {
+ if (snapshot.composer.queue.edit) return snapshot;
+ const composer = updater(snapshot.composer);
+ didMutate = composer !== snapshot.composer;
+ return didMutate ? { ...snapshot, composer } : snapshot;
+ });
+ return didMutate;
},
[runtimeStore]
);
@@ -1538,6 +1665,22 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
const input = activeRuntime.composer.input;
const draftProjectId = activeRuntime.composer.draftProjectId;
const isGenerating = activeRuntime.isGenerating;
+ const queuedMessages = activeRuntime.composer.queue.items;
+ const queueEdit = activeRuntime.composer.queue.edit;
+ const stoppingRuntimeRegistry = useMemo(
+ () => chatStoppingRuntimeRegistryFor(runtimeStore),
+ [runtimeStore]
+ );
+ useSyncExternalStore(
+ stoppingRuntimeRegistry.subscribe,
+ stoppingRuntimeRegistry.getSnapshot,
+ stoppingRuntimeRegistry.getSnapshot
+ );
+ const isStopping = Array.from(stoppingRuntimeRegistry.getEntries()).some(
+ ([key, runTokens]) =>
+ runTokens.size > 0 &&
+ runtimeStore.resolveKey(key) === runtimeStore.resolveKey(activeRuntime.key)
+ );
const [isSidebarOpen, setIsSidebarOpen] = usePersistentSidebarState(isCompactLayout);
const [isSidebarTransitioning, setIsSidebarTransitioning] = useState(false);
const error = activeRuntime.error;
@@ -1561,6 +1704,21 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
attachmentError,
audioError
} = activeRuntime.composer;
+ const editedQueuedMessage = queueEdit
+ ? queuedMessages.find((item) => item.queueId === queueEdit.queueId)
+ : undefined;
+ const canSubmitMessage = canSubmitChatComposer({
+ text: input,
+ hasAttachments: queueEdit
+ ? Boolean(editedQueuedMessage?.draftImages.length || editedQueuedMessage?.documentText)
+ : Boolean(draftImages.length || documentText),
+ hasQueuedMessages: queuedMessages.length > 0,
+ isEditingQueuedMessage: Boolean(queueEdit),
+ hasActiveRun: isGenerating,
+ isProcessingDocument,
+ isStopping
+ });
+ const showsStop = chatComposerShowsStop(isGenerating, isStopping);
const setConversationForKey = useCallback(
(key: ChatRuntimeKey, update: StateUpdate) => {
@@ -2424,44 +2582,214 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
};
}, [chatId, runtimeStore, selectConversationRuntime, selectFreshDraftRuntime, selectedProjectId]);
+ const settleLocallyCancelledRun = useCallback(
+ (runtimeKey: ChatRuntimeKey, runToken: number, optimisticMessageId: string | undefined) => {
+ const cancelled = runtimeStore.cancelRun(runtimeKey, runToken);
+ if (!cancelled) return false;
+ clearUnresolvedChatResponseMessage(runtimeStore, runToken);
+ runtimeStore.update(runtimeKey, (snapshot) => ({
+ ...snapshot,
+ error: snapshot.error === CHAT_STOP_WAITING_MESSAGE ? null : snapshot.error,
+ messages: cancelled.responseId
+ ? updateActiveItemStatuses(snapshot.messages as Message[], "incomplete")
+ : updateActiveItemStatuses(
+ markOptimisticMessageIncomplete(snapshot.messages as Message[], optimisticMessageId),
+ "incomplete"
+ )
+ }));
+ if (optimisticMessageId) {
+ unregisterChatOptimisticMessage(runtimeStore, runToken, optimisticMessageId);
+ }
+ return true;
+ },
+ [runtimeStore]
+ );
+
+ const cancelKnownChatResponse = useCallback(
+ async (
+ runtimeKey: ChatRuntimeKey,
+ runToken: number,
+ responseId: string,
+ optimisticMessageId: string | undefined
+ ): Promise => {
+ if (!openai) return false;
+ try {
+ await (
+ openai.responses as {
+ cancel: (id: string, options?: { timeout?: number }) => Promise;
+ }
+ ).cancel(responseId, { timeout: CHAT_STOP_REQUEST_TIMEOUT_MS });
+ return settleLocallyCancelledRun(runtimeKey, runToken, optimisticMessageId);
+ } catch (error) {
+ console.error("Failed to cancel response:", error);
+ if (!isChatResponseCancellationAlreadyTerminalError(error)) {
+ if (runtimeStore.isRunCurrent(runtimeKey, runToken)) {
+ setErrorForKey(runtimeKey, "Failed to cancel response. Please try Stop again.");
+ }
+ return false;
+ }
+ try {
+ const response = await openai.responses.retrieve(responseId, undefined, {
+ timeout: CHAT_STOP_REQUEST_TIMEOUT_MS
+ });
+ const status = (response as { status?: string }).status;
+ const runIsCurrent = runtimeStore.isRunCurrent(runtimeKey, runToken);
+
+ if (status === "completed") {
+ // Stop intent is sticky across this reconciliation. Settle now even
+ // if a buffered stream is healthy so the outer loop cannot promote
+ // a queued turn after this helper releases the Stop UI fence. The
+ // persisted user/item cursor remains valid for ascending polling.
+ if (runIsCurrent) {
+ runtimeStore.completeRunAndAbort(runtimeKey, runToken, (owned) => ({
+ ...owned,
+ messages: updateActiveItemStatuses(owned.messages as Message[], "completed"),
+ error: "The response finished before it could be stopped."
+ }));
+ clearUnresolvedChatResponseMessage(runtimeStore, runToken);
+ return true;
+ }
+ return false;
+ }
+
+ if (status === "failed" || status === "cancelled" || status === "incomplete") {
+ return settleLocallyCancelledRun(runtimeKey, runToken, optimisticMessageId);
+ }
+ } catch (reconciliationError) {
+ console.error(
+ "Failed to reconcile response after cancellation error:",
+ reconciliationError
+ );
+ }
+
+ if (runtimeStore.isRunCurrent(runtimeKey, runToken)) {
+ setErrorForKey(runtimeKey, "Failed to cancel response. Please try Stop again.");
+ }
+ return false;
+ } finally {
+ // Keep this fence through cancellation/retrieval so the send loop
+ // cannot promote a later FIFO item under the same outer run token.
+ stoppingRuntimeRegistry.delete(runtimeKey, runToken);
+ if (!runtimeStore.isRunCurrent(runtimeKey, runToken)) {
+ clearChatRunQueueHalt(runtimeStore, runToken);
+ }
+ }
+ },
+ [openai, runtimeStore, setErrorForKey, settleLocallyCancelledRun, stoppingRuntimeRegistry]
+ );
+
+ const reconcileDetachedChatResponse = useCallback(
+ async (runtimeKey: ChatRuntimeKey) => {
+ const snapshot = runtimeStore.get(runtimeKey);
+ if (
+ !openai ||
+ !snapshot?.isGenerating ||
+ snapshot.runToken === null ||
+ !snapshot.currentResponseId
+ ) {
+ return;
+ }
+
+ const runToken = snapshot.runToken;
+ const responseId = snapshot.currentResponseId;
+ const reconciliationKey = `${runtimeStore.resolveKey(runtimeKey)}:${runToken}:${responseId}`;
+ if (responseReconciliationsInFlightRef.current.has(reconciliationKey)) return;
+ responseReconciliationsInFlightRef.current.add(reconciliationKey);
+
+ try {
+ if (isChatRunQueueHaltRequested(runtimeStore, runToken)) {
+ await cancelKnownChatResponse(runtimeKey, runToken, responseId, undefined);
+ return;
+ }
+ const response = await openai.responses.retrieve(responseId, undefined, {
+ timeout: CHAT_STOP_REQUEST_TIMEOUT_MS
+ });
+ const current = runtimeStore.get(runtimeKey);
+ if (!current || current.runToken !== runToken || current.currentResponseId !== responseId) {
+ return;
+ }
+
+ const resolution = classifyChatResponseReconciliation(
+ (response as { status?: string | null }).status
+ );
+ if (resolution === "completed") {
+ runtimeStore.completeRunAndAbort(runtimeKey, runToken, (owned) => ({
+ ...owned,
+ messages: updateActiveItemStatuses(owned.messages as Message[], "completed"),
+ error:
+ "The response completed after the connection was restored. Queued messages were kept."
+ }));
+ clearUnresolvedChatResponseMessage(runtimeStore, runToken);
+ stoppingRuntimeRegistry.delete(runtimeKey, runToken);
+ clearChatRunQueueHalt(runtimeStore, runToken);
+ } else if (resolution === "terminal") {
+ if (settleLocallyCancelledRun(runtimeKey, runToken, undefined)) {
+ setErrorForKey(
+ runtimeKey,
+ "The interrupted response stopped. Queued messages were kept."
+ );
+ stoppingRuntimeRegistry.delete(runtimeKey, runToken);
+ clearChatRunQueueHalt(runtimeStore, runToken);
+ }
+ }
+ } catch (error) {
+ console.error("Failed to reconcile interrupted response:", error);
+ } finally {
+ responseReconciliationsInFlightRef.current.delete(reconciliationKey);
+ }
+ },
+ [
+ cancelKnownChatResponse,
+ openai,
+ runtimeStore,
+ setErrorForKey,
+ settleLocallyCancelledRun,
+ stoppingRuntimeRegistry
+ ]
+ );
+
// Cancel the current response
const handleCancelResponse = useCallback(async () => {
- const runtimeKey = activeRuntimeKeyRef.current;
- const runToken = runtimeStore.get(runtimeKey)?.runToken;
- if (runToken === null || runToken === undefined) return;
+ const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current);
+ const runSnapshot = runtimeStore.get(runtimeKey);
+ if (!runSnapshot || runSnapshot.runToken === null) return;
+ const runToken = runSnapshot.runToken;
+ stoppingRuntimeRegistry.add(runtimeKey, runToken);
+ requestChatRunQueueHalt(runtimeStore, runToken);
const optimisticMessageId = getRegisteredChatOptimisticMessage(runtimeStore, runToken);
+ const restoredBeforeRequest = restoreRegisteredChatTurnBeforeRequest(
+ runtimeStore,
+ runToken,
+ "Stopped before sending. Your message was restored."
+ );
// Commit the final partial frame while this run still owns its token. Once
// cancelRun clears ownership, any delayed callback must fail closed.
flushRegisteredChatStreamDeltas(runtimeStore, runToken);
- const cancelled = runtimeStore.cancelRun(runtimeKey, runToken);
- if (!cancelled) return;
-
- runtimeStore.update(runtimeKey, (snapshot) => ({
- ...snapshot,
- messages: cancelled.responseId
- ? updateActiveItemStatuses(snapshot.messages as Message[], "incomplete")
- : updateActiveItemStatuses(
- markOptimisticMessageIncomplete(snapshot.messages as Message[], optimisticMessageId),
- "incomplete"
- )
- }));
- if (optimisticMessageId) {
- unregisterChatOptimisticMessage(runtimeStore, runToken, optimisticMessageId);
+
+ if (restoredBeforeRequest) {
+ settleLocallyCancelledRun(runtimeKey, runToken, optimisticMessageId);
+ stoppingRuntimeRegistry.delete(runtimeKey, runToken);
+ return;
}
- try {
- if (cancelled.responseId && openai) {
- await (openai.responses as { cancel: (id: string) => Promise }).cancel(
- cancelled.responseId
- );
- }
- } catch (error) {
- console.error("Failed to cancel response:", error);
- if (runtimeStore.get(runtimeKey)) {
- setErrorForKey(runtimeKey, "Failed to cancel response. Please try again.");
- }
+ const currentResponseId = runtimeStore.get(runtimeKey)?.currentResponseId;
+ if (currentResponseId) {
+ await cancelKnownChatResponse(runtimeKey, runToken, currentResponseId, optimisticMessageId);
+ return;
}
- }, [openai, runtimeStore, setErrorForKey]);
+
+ // The POST may already be accepted even though response.created has not
+ // arrived. Preserve the run and Stop fence; the stream handler will cancel
+ // immediately when it learns the server response ID. Local cancellation
+ // here would orphan server work and allow the FIFO to overlap it.
+ setErrorForKey(runtimeKey, CHAT_STOP_WAITING_MESSAGE);
+ }, [
+ cancelKnownChatResponse,
+ runtimeStore,
+ setErrorForKey,
+ settleLocallyCancelledRun,
+ stoppingRuntimeRegistry
+ ]);
// Load conversation from API
const loadConversation = useCallback(
@@ -2513,6 +2841,32 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
const newestCompletedItem = itemsResponse.data.find(
(item) => (item as Message).status !== "in_progress"
);
+ const snapshotBeforeItems = runtimeStore.get(runtimeKey);
+ if (
+ snapshotBeforeItems?.isGenerating &&
+ !snapshotBeforeItems.currentResponseId &&
+ snapshotBeforeItems.runToken !== null
+ ) {
+ const unresolvedMessageId = getUnresolvedChatResponseMessage(
+ runtimeStore,
+ snapshotBeforeItems.runToken
+ );
+ const recoveredResponseId = unresolvedMessageId
+ ? responseIdForChatMessage(unresolvedMessageId, itemsResponse.data)
+ : undefined;
+ if (recoveredResponseId) {
+ runtimeStore.setCurrentResponseId(
+ runtimeKey,
+ snapshotBeforeItems.runToken,
+ recoveredResponseId
+ );
+ clearUnresolvedChatResponseMessage(
+ runtimeStore,
+ snapshotBeforeItems.runToken,
+ unresolvedMessageId
+ );
+ }
+ }
runtimeStore.update(runtimeKey, (snapshot) => ({
...snapshot,
messages: mergeLoadedMessagesWithRuntime(messagesInChronologicalOrder, snapshot.messages),
@@ -2719,20 +3073,88 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
const conversationId =
snapshot?.conversation?.id ??
conversationIdFromChatRuntimeKey(runtimeStore.resolveKey(runtimeKey));
- if (!snapshot || !conversationId || !openai || snapshot.assistantStreaming) return;
+ if (
+ !snapshot ||
+ !conversationId ||
+ !openai ||
+ (snapshot.assistantStreaming && snapshot.currentResponseId)
+ ) {
+ return;
+ }
try {
+ if (!snapshot.currentResponseId && snapshot.runToken !== null) {
+ const unresolvedMessageId = getUnresolvedChatResponseMessage(
+ runtimeStore,
+ snapshot.runToken
+ );
+ // Retrieve the exact persisted user item. A list page can contain ten
+ // image-description call/output pairs before the user item and is not
+ // a reliable response-ownership lookup.
+ if (unresolvedMessageId) {
+ try {
+ const linkedItem = await openai.conversations.items.retrieve(unresolvedMessageId, {
+ conversation_id: conversationId
+ });
+ const recoveredResponseId = responseIdForChatMessage(unresolvedMessageId, [
+ linkedItem
+ ]);
+ if (
+ recoveredResponseId &&
+ runtimeStore.setCurrentResponseId(
+ runtimeKey,
+ snapshot.runToken,
+ recoveredResponseId
+ )
+ ) {
+ clearUnresolvedChatResponseMessage(
+ runtimeStore,
+ snapshot.runToken,
+ unresolvedMessageId
+ );
+ }
+ } catch (error) {
+ const status = (error as { status?: unknown })?.status;
+ if (status !== 404) console.error("Response ownership polling error:", error);
+ }
+ }
+ }
+
// Fetch NEW items that came after the last seen ID
// Use order=asc to get items chronologically after the lastSeenItemId
+ const hasCursor = Boolean(snapshot.lastSeenItemId);
const response = await openai.conversations.items.list(conversationId, {
- ...(snapshot.lastSeenItemId ? { after: snapshot.lastSeenItemId, order: "asc" } : {}),
+ ...(snapshot.lastSeenItemId ? { after: snapshot.lastSeenItemId } : {}),
+ order: hasCursor ? "asc" : "desc",
limit: 20 // Smaller limit since we only expect a few new messages
});
+ const pollingPage = normalizeChatPollingPage(response.data, hasCursor);
+
+ if (!snapshot.currentResponseId && snapshot.runToken !== null) {
+ const unresolvedMessageId = getUnresolvedChatResponseMessage(
+ runtimeStore,
+ snapshot.runToken
+ );
+ const recoveredResponseId = unresolvedMessageId
+ ? responseIdForChatMessage(unresolvedMessageId, response.data)
+ : undefined;
+ if (recoveredResponseId) {
+ if (
+ runtimeStore.setCurrentResponseId(runtimeKey, snapshot.runToken, recoveredResponseId)
+ ) {
+ clearUnresolvedChatResponseMessage(
+ runtimeStore,
+ snapshot.runToken,
+ unresolvedMessageId
+ );
+ }
+ }
+ }
if (response.data.length > 0) {
// Convert API items to UI messages, grouping tool calls with their messages
const newMessages = convertItemsToMessages(
- response.data as Array<{
+ pollingPage.chronologicalItems as Array<{
id: string;
type: string;
role?: string;
@@ -2767,10 +3189,8 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
// Update last seen item ID for next poll
// Since we're using order=asc, the LAST item is the newest
// Skip in_progress messages by finding the last completed one
- const newestCompletedItem = [...response.data]
- .reverse()
- .find((item) => (item as Message).status !== "in_progress");
- if (newestCompletedItem) {
+ const newestCompletedItem = pollingPage.newestCompletedItem;
+ if (newestCompletedItem?.id) {
setLastSeenItemIdForKey(runtimeKey, newestCompletedItem.id);
}
}
@@ -2779,8 +3199,28 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
console.error("Polling error:", error);
// Don't throw - polling should fail silently
}
+ if (!runtimeStore.get(runtimeKey)?.conversation) {
+ try {
+ const conversation = (await openai.conversations.retrieve(
+ conversationId
+ )) as Conversation;
+ if (runtimeStore.get(runtimeKey)) {
+ runtimeStore.update(runtimeKey, (current) => ({ ...current, conversation }));
+ runtimeStore.updateActivityGroup(runtimeKey, conversation.project_id ?? null);
+ }
+ } catch (error) {
+ console.error("Conversation metadata polling error:", error);
+ }
+ }
+ await reconcileDetachedChatResponse(runtimeKey);
},
- [isRuntimeSelected, openai, runtimeStore, setLastSeenItemIdForKey]
+ [
+ isRuntimeSelected,
+ openai,
+ reconcileDetachedChatResponse,
+ runtimeStore,
+ setLastSeenItemIdForKey
+ ]
);
// Load conversation when URL changes or on mount. Cached runtimes—including
@@ -2795,8 +3235,11 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
}, [activeRuntimeKey, chatId, openai, loadConversation, runtimeStore]);
// Set up progressive polling interval
+ const pollingConversationId =
+ conversation?.id ?? conversationIdFromChatRuntimeKey(runtimeStore.resolveKey(activeRuntimeKey));
+
useEffect(() => {
- if (!conversation?.id || !openai) return;
+ if (!pollingConversationId || !openai) return;
const runtimeKey = activeRuntimeKey;
// Progressive intervals: 2s, 5s, 10s, 15s, 20s, 30s, 60s (then 60s forever)
@@ -2827,7 +3270,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
- }, [activeRuntimeKey, conversation?.id, openai, pollForNewItems]);
+ }, [activeRuntimeKey, openai, pollForNewItems, pollingConversationId]);
// Poll for title updates when it's "New Conversation" with exponential backoff
useEffect(() => {
@@ -3400,7 +3843,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
const selectedFiles = Array.from(e.currentTarget.files ?? []);
e.currentTarget.value = "";
const ownerSnapshot = runtimeStore.get(ownerKey);
- if (selectedFiles.length === 0 || !ownerSnapshot || ownerSnapshot.isGenerating) return;
+ if (selectedFiles.length === 0 || !ownerSnapshot || ownerSnapshot.composer.queue.edit) return;
const supportedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
const maxSizeInBytes = 20 * 1024 * 1024;
@@ -3417,26 +3860,50 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
}
return true;
});
- if (validationError) {
+ if (validFiles.length === 0) {
+ if (validationError) {
+ setComposerErrorForKey(ownerKey, "attachmentError", validationError);
+ }
+ return;
+ }
+ const accountUsage = chatAccountQueueUsage(runtimeStore);
+ const selection = selectChatImageFilesForRetention({
+ composer: ownerSnapshot.composer,
+ candidates: validFiles,
+ accountUsage
+ });
+ if (selection.accountLimitExceeded) {
+ setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_IMAGE_LIMIT_MESSAGE);
+ } else if (selection.messageLimitExceeded) {
+ setComposerErrorForKey(ownerKey, "attachmentError", CHAT_MESSAGE_IMAGE_LIMIT_MESSAGE);
+ } else if (validationError) {
setComposerErrorForKey(ownerKey, "attachmentError", validationError);
}
- if (validFiles.length === 0) return;
+ if (selection.files.length === 0) return;
+ const additionalBytes = selection.files.reduce((total, file) => total + file.size, 0);
+ if (
+ accountUsage.attachmentBytes + additionalBytes >
+ MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES
+ ) {
+ setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE);
+ return;
+ }
- const newUrls = validFiles.map((file) => [file, URL.createObjectURL(file)] as const);
- const attached = updateIdleAttachmentComposerForKey(ownerKey, (composer) => ({
+ const newUrls = selection.files.map((file) => [file, URL.createObjectURL(file)] as const);
+ const attached = updateAttachmentComposerForKey(ownerKey, (composer) => ({
...composer,
imageUrls: new Map([...composer.imageUrls, ...newUrls]),
- draftImages: [...composer.draftImages, ...validFiles]
+ draftImages: [...composer.draftImages, ...selection.files]
}));
if (!attached) for (const [, url] of newUrls) URL.revokeObjectURL(url);
},
- [runtimeStore, setComposerErrorForKey, updateIdleAttachmentComposerForKey]
+ [runtimeStore, setComposerErrorForKey, updateAttachmentComposerForKey]
);
const attachPastedImages = useCallback(
(imageFiles: File[], ownerKey: ChatRuntimeKey, expectedGeneration: number) => {
const ownerSnapshot = runtimeStore.get(ownerKey);
- if (!ownerSnapshot || ownerSnapshot.isGenerating) return;
+ if (!ownerSnapshot || ownerSnapshot.composer.queue.edit) return;
if (!canUseImages) {
if (isRuntimeSelected(ownerKey)) {
@@ -3461,21 +3928,44 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
}
return true;
});
- if (validationError) {
+ if (validFiles.length === 0) {
+ if (validationError) {
+ setComposerErrorForKey(ownerKey, "attachmentError", validationError);
+ }
+ return;
+ }
+ const accountUsage = chatAccountQueueUsage(runtimeStore);
+ const selection = selectChatImageFilesForRetention({
+ composer: ownerSnapshot.composer,
+ candidates: validFiles,
+ accountUsage
+ });
+ if (selection.accountLimitExceeded) {
+ setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_IMAGE_LIMIT_MESSAGE);
+ } else if (selection.messageLimitExceeded) {
+ setComposerErrorForKey(ownerKey, "attachmentError", CHAT_MESSAGE_IMAGE_LIMIT_MESSAGE);
+ } else if (validationError) {
setComposerErrorForKey(ownerKey, "attachmentError", validationError);
}
+ if (selection.files.length === 0) return;
+ const additionalBytes = selection.files.reduce((total, file) => total + file.size, 0);
+ if (
+ accountUsage.attachmentBytes + additionalBytes >
+ MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES
+ ) {
+ setComposerErrorForKey(ownerKey, "attachmentError", CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE);
+ return;
+ }
- if (validFiles.length === 0) return;
-
- const newUrls = validFiles.map((file) => [file, URL.createObjectURL(file)] as const);
+ const newUrls = selection.files.map((file) => [file, URL.createObjectURL(file)] as const);
let generationMatched = false;
- const attached = updateIdleAttachmentComposerForKey(ownerKey, (composer) => {
+ const attached = updateAttachmentComposerForKey(ownerKey, (composer) => {
if (composer.imagePasteGeneration !== expectedGeneration) return composer;
generationMatched = true;
return {
...composer,
imageUrls: new Map([...composer.imageUrls, ...newUrls]),
- draftImages: [...composer.draftImages, ...validFiles]
+ draftImages: [...composer.draftImages, ...selection.files]
};
});
if (!attached || !generationMatched) {
@@ -3487,7 +3977,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
isRuntimeSelected,
runtimeStore,
setComposerErrorForKey,
- updateIdleAttachmentComposerForKey
+ updateAttachmentComposerForKey
]
);
@@ -3495,7 +3985,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
(e: React.ClipboardEvent) => {
const ownerKey = activeRuntimeKeyRef.current;
const startSnapshot = runtimeStore.get(ownerKey);
- if (!startSnapshot || startSnapshot.isGenerating) return;
+ if (!startSnapshot || startSnapshot.composer.queue.edit) return;
const pasteGeneration = startSnapshot.composer.imagePasteGeneration + 1;
updateComposerForKey(ownerKey, (composer) => ({
...composer,
@@ -3550,11 +4040,11 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
(idx: number) => {
const ownerKey = activeRuntimeKeyRef.current;
const snapshot = runtimeStore.get(ownerKey);
- if (!snapshot || snapshot.isGenerating) return;
+ if (!snapshot || snapshot.composer.queue.edit) return;
const fileToRemove = snapshot?.composer.draftImages[idx];
if (!fileToRemove) return;
const url = snapshot.composer.imageUrls.get(fileToRemove);
- const removed = updateIdleAttachmentComposerForKey(ownerKey, (composer) => {
+ const removed = updateAttachmentComposerForKey(ownerKey, (composer) => {
const nextUrls = new Map(composer.imageUrls);
nextUrls.delete(fileToRemove);
return {
@@ -3565,7 +4055,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
});
if (removed && url) URL.revokeObjectURL(url);
},
- [runtimeStore, updateIdleAttachmentComposerForKey]
+ [runtimeStore, updateAttachmentComposerForKey]
);
const handleDocumentUpload = useCallback(
@@ -3577,7 +4067,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
if (!file) return;
const startSnapshot = runtimeStore.get(ownerKey);
- if (!startSnapshot || startSnapshot.isGenerating) {
+ if (!startSnapshot || startSnapshot.composer.queue.edit) {
inputElement.value = "";
return;
}
@@ -3590,7 +4080,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
}
const uploadGeneration = startSnapshot.composer.documentUploadGeneration + 1;
- const started = updateIdleAttachmentComposerForKey(ownerKey, (composer) => ({
+ const started = updateAttachmentComposerForKey(ownerKey, (composer) => ({
...composer,
isProcessingDocument: true,
attachmentError: null,
@@ -3605,13 +4095,28 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
updater: (composer: ChatComposerState) => ChatComposerState
) => {
let generationMatched = false;
- const updated = updateIdleAttachmentComposerForKey(ownerKey, (composer) => {
+ const updated = updateAttachmentComposerForKey(ownerKey, (composer) => {
if (composer.documentUploadGeneration !== uploadGeneration) return composer;
generationMatched = true;
return updater(composer);
});
return updated && generationMatched;
};
+ const retainDocumentIfWithinAccountBudget = (documentText: string, documentName: string) =>
+ updateDocumentIfCurrent((composer) => {
+ const accountUsage = chatAccountQueueUsage(runtimeStore);
+ const nextAttachmentBytes =
+ accountUsage.attachmentBytes -
+ chatQueuedTextByteLength(composer.documentText) +
+ chatQueuedTextByteLength(documentText);
+ if (nextAttachmentBytes > MAX_CHAT_ACCOUNT_RETAINED_ATTACHMENT_BYTES) {
+ return {
+ ...composer,
+ attachmentError: CHAT_ACCOUNT_ATTACHMENT_LIMIT_MESSAGE
+ };
+ }
+ return { ...composer, documentText, documentName };
+ });
try {
const documentType = getSupportedDocumentType(file.name);
@@ -3624,11 +4129,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
text_content: text
}
};
- updateDocumentIfCurrent((composer) => ({
- ...composer,
- documentText: JSON.stringify(documentData),
- documentName: file.name
- }));
+ retainDocumentIfWithinAccountBudget(JSON.stringify(documentData), file.name);
} else if (documentType && isNativeDocumentType(documentType) && isTauriEnv) {
const result = await extractDocumentContent(file, documentType);
if (runtimeStore.get(ownerKey)?.composer.documentUploadGeneration !== uploadGeneration)
@@ -3656,11 +4157,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
}
};
- updateDocumentIfCurrent((composer) => ({
- ...composer,
- documentText: JSON.stringify(cleanedParsed),
- documentName: file.name
- }));
+ retainDocumentIfWithinAccountBudget(JSON.stringify(cleanedParsed), file.name);
} else if (documentType && isNativeDocumentType(documentType)) {
setComposerErrorForKey(
ownerKey,
@@ -3684,26 +4181,36 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
);
}
} finally {
- updateDocumentIfCurrent((composer) => ({
- ...composer,
- isProcessingDocument: false
- }));
+ // An edit may begin while native document extraction is in flight.
+ // Attachment mutation stays blocked during the edit, but the owned
+ // processing flag must always settle so the composer cannot deadlock.
+ updateComposerForKey(ownerKey, (composer) =>
+ composer.documentUploadGeneration === uploadGeneration
+ ? { ...composer, isProcessingDocument: false }
+ : composer
+ );
inputElement.value = "";
}
},
- [isTauriEnv, runtimeStore, setComposerErrorForKey, updateIdleAttachmentComposerForKey]
+ [
+ isTauriEnv,
+ runtimeStore,
+ setComposerErrorForKey,
+ updateAttachmentComposerForKey,
+ updateComposerForKey
+ ]
);
const removeDocument = useCallback(() => {
const ownerKey = activeRuntimeKeyRef.current;
- updateIdleAttachmentComposerForKey(ownerKey, (composer) => ({
+ updateAttachmentComposerForKey(ownerKey, (composer) => ({
...composer,
isProcessingDocument: false,
documentText: "",
documentName: "",
documentUploadGeneration: composer.documentUploadGeneration + 1
}));
- }, [updateIdleAttachmentComposerForKey]);
+ }, [updateAttachmentComposerForKey]);
// Audio recording functions
const startRecording = async () => {
@@ -3972,14 +4479,102 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
}
};
- // Helper function to process streaming response - used by both initial request and retry
+ const discardQueueEdit = useCallback(() => {
+ const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current);
+ if (!runtimeStore.get(runtimeKey)) return;
+ runtimeStore.update(runtimeKey, (snapshot) => {
+ const discarded = discardChatQueuedMessageEdit(snapshot.composer.queue);
+ if (discarded.status !== "ended") return snapshot;
+ return {
+ ...snapshot,
+ composer: {
+ ...snapshot.composer,
+ input: discarded.restoreInput,
+ queue: discarded.queue
+ }
+ };
+ });
+ }, [runtimeStore]);
+
+ const cancelQueuedMessage = useCallback(
+ (queueId: string) => {
+ const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current);
+ let removedItem: ChatQueuedMessage | null = null;
+ let retainedObjectUrls = new Set();
+ if (!runtimeStore.get(runtimeKey)) return;
+ runtimeStore.update(runtimeKey, (snapshot) => {
+ const cancelled = cancelChatQueuedMessage(snapshot.composer.queue, queueId);
+ if (cancelled.status !== "cancelled") return snapshot;
+ removedItem = cancelled.item;
+ const composer = {
+ ...snapshot.composer,
+ input: cancelled.restoreInput ?? snapshot.composer.input,
+ queue: cancelled.queue
+ };
+ retainedObjectUrls = new Set([
+ ...chatComposerObjectUrls(composer),
+ ...chatTranscriptObjectUrls(snapshot.messages as Message[])
+ ]);
+ return {
+ ...snapshot,
+ composer
+ };
+ });
+ if (removedItem) {
+ revokeQueuedChatMessageObjectUrls(removedItem, retainedObjectUrls);
+ }
+ },
+ [runtimeStore]
+ );
+
+ const editQueuedMessage = useCallback(
+ (queueId: string) => {
+ const runtimeKey = runtimeStore.resolveKey(activeRuntimeKeyRef.current);
+ let shouldFocus = false;
+ if (!runtimeStore.get(runtimeKey)) return;
+ runtimeStore.update(runtimeKey, (snapshot) => {
+ const started = beginChatQueuedMessageEdit(
+ snapshot.composer.queue,
+ runtimeKey,
+ queueId,
+ snapshot.composer.input
+ );
+ if (started.status === "already_editing") {
+ const discarded = discardChatQueuedMessageEdit(snapshot.composer.queue);
+ if (discarded.status !== "ended") return snapshot;
+ return {
+ ...snapshot,
+ composer: {
+ ...snapshot.composer,
+ input: discarded.restoreInput,
+ queue: discarded.queue
+ }
+ };
+ }
+ if (started.status !== "started") return snapshot;
+ shouldFocus = true;
+ return {
+ ...snapshot,
+ composer: {
+ ...snapshot.composer,
+ input: started.input,
+ queue: started.queue
+ }
+ };
+ });
+ if (shouldFocus) requestAnimationFrame(() => textareaRef.current?.focus());
+ },
+ [runtimeStore]
+ );
+
+ // Helper function to process one streaming response.
const processStreamingResponse = useCallback(
async (
stream: AsyncIterable,
runtimeKey: ChatRuntimeKey,
runToken: number,
optimisticMessageId: string,
- discardOwnedItemsOnError: boolean
+ onResponseCreated: (responseId: string) => Promise
): Promise => {
const messageTextBuffers = new Map>();
const reasoningTextBuffers = new Map>();
@@ -4109,12 +4704,15 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
if (eventType === "response.created") {
unregisterChatOptimisticMessage(runtimeStore, runToken, optimisticMessageId);
const eventWithResponse = event as { response?: { id?: string } };
- if (eventWithResponse.response?.id) {
- runtimeStore.setCurrentResponseId(
- runtimeKey,
- runToken,
- eventWithResponse.response.id
- );
+ const responseId = eventWithResponse.response?.id;
+ if (responseId) {
+ if (runtimeStore.setCurrentResponseId(runtimeKey, runToken, responseId)) {
+ clearUnresolvedChatResponseMessage(runtimeStore, runToken, optimisticMessageId);
+ }
+ if (await onResponseCreated(responseId)) {
+ terminalState = "cancelled";
+ break;
+ }
}
} else if (eventType === "response.output_item.added") {
const addedEvent = event as ResponseOutputItemAddedEvent;
@@ -4290,6 +4888,9 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
}
} else if (eventType === "response.completed") {
terminalState = "completed";
+ // This is the authoritative terminal frame. Do not let a later
+ // transport-close error turn a durable completion into a retry.
+ break;
} else if (isTerminalChatStreamErrorEvent(eventType)) {
terminalState = "error";
console.error("Streaming error:", event);
@@ -4323,11 +4924,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
// and account teardown clear run ownership before aborting, so their
// resulting iterator errors remain stale-fenced here.
deltaCoalescer.finish();
- updateRunMessages((messages) =>
- discardOwnedItemsOnError
- ? removeOwnedChatStreamAttemptItems(messages, ownedItemIds)
- : updateActiveItemStatuses(messages, "error", ownedItemIds)
- );
+ updateRunMessages((messages) => updateActiveItemStatuses(messages, "error", ownedItemIds));
throw error;
} finally {
// Natural EOF and thrown stream errors both commit the final partial
@@ -4342,134 +4939,158 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
[logStreamEvent, runtimeStore]
);
- // Every send captures its owning runtime key. Navigation only changes the
- // projected runtime; it never changes where this request or its SSE events land.
+ // Every submit synchronously detaches its owning composer. During an active
+ // run that creates a staged chip; otherwise one outer run drains the FIFO as
+ // separate Responses turns without an idle run-token or AbortController gap.
const handleSendMessage = useCallback(
async (e?: React.FormEvent, overrideInput?: string, ownerRuntimeKey?: ChatRuntimeKey) => {
e?.preventDefault();
let runtimeKey = runtimeStore.resolveKey(ownerRuntimeKey ?? activeRuntimeKeyRef.current);
const startSnapshot = runtimeStore.get(runtimeKey);
- if (!startSnapshot || !openai) return;
-
- const originalComposer = startSnapshot.composer;
- const textToSend = overrideInput ?? originalComposer.input;
- const trimmedInput = textToSend.trim();
- const originalImages = [...originalComposer.draftImages];
- const originalDocumentText = originalComposer.documentText;
- const originalDocumentName = originalComposer.documentName;
- const hasContent =
- trimmedInput.length > 0 || originalImages.length > 0 || originalDocumentText.length > 0;
- if (!hasContent || startSnapshot.isGenerating || originalComposer.isProcessingDocument) {
+ if (!startSnapshot) return;
+ const retainOverrideInput = () => {
+ if (overrideInput === undefined) return;
+ runtimeStore.update(runtimeKey, (snapshot) => ({
+ ...snapshot,
+ composer: chatComposerWithInputOverride(snapshot.composer, overrideInput)
+ }));
+ };
+ if (!openai || isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) {
+ retainOverrideInput();
+ return;
+ }
+ const isRuntimeStopping = (runToken?: number) =>
+ Array.from(stoppingRuntimeRegistry.getEntries()).some(
+ ([key, runTokens]) =>
+ (runToken === undefined || runTokens.has(runToken)) &&
+ runtimeStore.resolveKey(key) === runtimeKey
+ );
+ if (isRuntimeStopping()) {
+ retainOverrideInput();
+ return;
+ }
+
+ const metadata: ChatQueuedMessageMetadata = {
+ queueId: uuidv4(),
+ messageId: uuidv4(),
+ model: model || DEFAULT_MODEL_ID,
+ webSearchEnabled: isWebSearchEnabled,
+ createdMs: Date.now()
+ };
+ const composerAtSubmit = chatComposerWithInputOverride(startSnapshot.composer, overrideInput);
+ const preflight = planChatComposerSubmission({
+ composer: composerAtSubmit,
+ hasActiveRun: startSnapshot.isGenerating,
+ metadata,
+ accountUsage: chatAccountQueueUsage(runtimeStore)
+ });
+ const submissionError = (status: typeof preflight.status): string | null => {
+ if (status === "queue_full") return "You can queue up to 16 messages.";
+ if (status === "text_too_large") return "Queued messages must be 32 KiB or smaller.";
+ if (status === "too_many_images") {
+ return "You can attach up to 10 images to a queued message.";
+ }
+ if (status === "image_too_large") {
+ return "Each queued image must be 20 MiB or smaller.";
+ }
+ if (status === "document_too_large") {
+ return "Queued documents must be 10 MiB or smaller.";
+ }
+ if (status === "queue_payload_too_large") {
+ return "Queued attachments can use up to 256 MiB in total.";
+ }
+ if (status === "account_queue_full") {
+ return "You can retain up to 64 queued chat messages across your account.";
+ }
+ if (status === "account_payload_too_large") {
+ return "Chat drafts and queued attachments can use up to 256 MiB across your account.";
+ }
+ if (status === "processing") return "Wait for the document to finish processing.";
+ if (status === "missing_edit") return "That queued message is no longer available.";
+ return null;
+ };
+
+ if (startSnapshot.isGenerating) {
+ runtimeStore.update(runtimeKey, (snapshot) => {
+ const composer = chatComposerWithInputOverride(snapshot.composer, overrideInput);
+ const plan = planChatComposerSubmission({
+ composer,
+ hasActiveRun: true,
+ metadata,
+ accountUsage: chatAccountQueueUsage(runtimeStore)
+ });
+ if (plan.status === "queued" || plan.status === "updated") {
+ return { ...snapshot, composer: plan.composer, error: null };
+ }
+ return { ...snapshot, error: submissionError(plan.status) ?? snapshot.error };
+ });
+ return;
+ }
+
+ if (preflight.status !== "start") {
+ const message = submissionError(preflight.status);
+ if (message) setErrorForKey(runtimeKey, message);
return;
}
- const requestModel = model || DEFAULT_MODEL_ID;
- const requestWebSearchEnabled = isWebSearchEnabled;
- const billingStatusAtSend = billingStatus;
- const existingConversationId =
- startSnapshot.conversation?.id ?? conversationIdFromChatRuntimeKey(runtimeKey);
- const isFollowUpConversation =
- Boolean(existingConversationId) && startSnapshot.messages.length > 1;
const run = runtimeStore.beginRun(runtimeKey, {
groupId:
startSnapshot.conversation?.project_id ??
- originalComposer.draftProjectId ??
+ preflight.item.draftProjectId ??
selectedProjectId ??
null
});
- const localMessageId = uuidv4();
- let conversationId = existingConversationId;
- let composerRestored = false;
- let adoptedExistingDestination = false;
- let completedSuccessfully = false;
-
- const restoreOriginComposer = (message: string) => {
- if (composerRestored) return true;
-
- let createdUrls: string[] = [];
- let displacedUrls: string[] = [];
- const restored = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => {
- const adoptedDestinationRecovery = recoverFailedSendAfterDestinationAdoption(
- adoptedExistingDestination,
- snapshot.messages as Message[],
- snapshot.composer,
- localMessageId
- );
- if (adoptedDestinationRecovery) {
- return {
- ...snapshot,
- messages: adoptedDestinationRecovery.messages,
- composer: adoptedDestinationRecovery.composer,
- error: message
- };
- }
-
- const restoredUrlPlan = planRestoredImageUrls(
- originalImages,
- snapshot.composer.imageUrls,
- (file) => URL.createObjectURL(file)
- );
- createdUrls = restoredUrlPlan.createdUrls;
- displacedUrls = restoredUrlPlan.displacedUrls;
-
- return {
- ...snapshot,
- messages: (snapshot.messages as Message[]).filter((item) => item.id !== localMessageId),
- error: message,
- composer: {
- ...snapshot.composer,
- input: textToSend,
- draftImages: originalImages,
- imageUrls: restoredUrlPlan.imageUrls,
- documentText: originalDocumentText,
- documentName: originalDocumentName,
- isProcessingDocument: false,
- attachmentError: null,
- imagePasteGeneration: snapshot.composer.imagePasteGeneration + 1,
- documentUploadGeneration: snapshot.composer.documentUploadGeneration + 1
- }
- };
- });
-
- if (!restored) {
- for (const url of createdUrls) URL.revokeObjectURL(url);
- return false;
- }
- for (const url of displacedUrls) URL.revokeObjectURL(url);
- composerRestored = true;
- return true;
+ type ChatQueueTurn = {
+ item: ChatQueuedMessage;
+ recoverOnFailure: boolean;
+ previousLastSeenItemId: string | undefined;
};
+ let currentTurn: ChatQueueTurn | undefined;
+ const started = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => {
+ const composer = chatComposerWithInputOverride(snapshot.composer, overrideInput);
+ const plan = planChatComposerSubmission({
+ composer,
+ hasActiveRun: false,
+ metadata,
+ accountUsage: chatAccountQueueUsage(runtimeStore)
+ });
+ if (plan.status !== "start") return snapshot;
+ currentTurn = {
+ item: plan.item,
+ recoverOnFailure: plan.recoverOnFailure,
+ previousLastSeenItemId: snapshot.lastSeenItemId
+ };
+ return {
+ ...snapshot,
+ composer: plan.composer,
+ messages: mergeMessagesById(snapshot.messages as Message[], [
+ promotedChatUserMessage(plan.item)
+ ]),
+ lastSeenItemId: plan.item.messageId,
+ error: null
+ };
+ });
+ if (!started || !currentTurn) {
+ runtimeStore.finishRun(runtimeKey, run.token);
+ return;
+ }
+ registerChatOptimisticMessage(runtimeStore, run.token, currentTurn.item.messageId);
+ if (
+ !isCompactLayout &&
+ startSnapshot.messages.length === 0 &&
+ isRuntimeSelected(runtimeKey)
+ ) {
+ // The first optimistic row swaps the centered composer for the bottom
+ // composer. Restore focus once after that remount so follow-ups can be
+ // queued immediately without focusing on every streamed item.
+ requestAnimationFrame(() => textareaRef.current?.focus());
+ }
- const createResponseStream = async (
- targetConversationId: string,
- discardOwnedItemsOnError: boolean
- ) => {
- const stream = await openai.responses.create(
- {
- conversation: targetConversationId,
- model: requestModel,
- input: [{ role: "user", content: messageContent }],
- metadata: { internal_message_id: localMessageId },
- stream: true,
- store: true,
- ...(requestWebSearchEnabled && { tools: [{ type: "web_search" }] })
- },
- { signal: run.signal }
- );
-
- if (!runtimeStore.setAssistantStreaming(runtimeKey, run.token, true)) return null;
- try {
- return await processStreamingResponse(
- stream,
- runtimeKey,
- run.token,
- localMessageId,
- discardOwnedItemsOnError
- );
- } finally {
- runtimeStore.setAssistantStreaming(runtimeKey, run.token, false);
- }
- };
+ let conversationId =
+ startSnapshot.conversation?.id ?? conversationIdFromChatRuntimeKey(runtimeKey);
+ let completedAnyTurn = false;
+ let stopOwnsSettlement = false;
+ let detachedResponseOwnsSettlement = false;
const scheduleBillingRefresh = () => {
const timeout = setTimeout(() => {
@@ -4479,305 +5100,520 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
billingRefreshTimeoutsRef.current.add(timeout);
};
- const messageContent: (InputTextContent | InputImageContent)[] = [];
- let finalText = trimmedInput;
- if (originalDocumentText) {
- finalText = originalDocumentText + (trimmedInput ? `\n\n${trimmedInput}` : "");
- }
- if (finalText) {
- messageContent.push({
- type: "input_text",
- text: finalText
- });
- }
+ const sendTurn = async (turn: ChatQueueTurn): Promise => {
+ const { item, recoverOnFailure, previousLastSeenItemId } = turn;
+ const localMessageId = item.messageId;
+ let ownsObjectUrls = true;
+ let responseRequestStarted = false;
+ let conversationCreateInFlight = false;
+ let turnRestored = false;
+ let preserveUnresolvedResponseOwnership = false;
+
+ const releaseObjectUrls = () => {
+ if (!ownsObjectUrls) return;
+ ownsObjectUrls = false;
+ const snapshot = runtimeStore.get(runtimeKey);
+ const retainedObjectUrls = new Set(
+ snapshot
+ ? [
+ ...chatComposerObjectUrls(snapshot.composer),
+ ...chatTranscriptObjectUrls(snapshot.messages as Message[])
+ ]
+ : []
+ );
+ revokeQueuedChatMessageObjectUrls(item, retainedObjectUrls);
+ };
- try {
- for (const file of originalImages) {
- try {
- const dataUrl = await fileToDataURL(file);
- messageContent.push({
- type: "input_image",
- image_url: dataUrl,
- detail: "auto",
- file_id: null
- });
- } catch (error) {
- console.error("Failed to convert image:", error);
+ const restoreTurn = (message: string) => {
+ if (turnRestored) return true;
+ let transferred = false;
+ const restored = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => {
+ const recovery = recoverDetachedChatComposerDraft(snapshot.composer, item);
+ let composer = recovery.composer;
+ if (!recoverOnFailure && recovery.status === "restored") {
+ composer = {
+ ...snapshot.composer,
+ queue: {
+ ...snapshot.composer.queue,
+ items: [item, ...snapshot.composer.queue.items]
+ }
+ };
+ }
+ transferred = true;
+ return {
+ ...snapshot,
+ messages: (snapshot.messages as Message[]).filter(
+ (messageItem) => messageItem.id !== localMessageId
+ ),
+ lastSeenItemId:
+ snapshot.lastSeenItemId === localMessageId
+ ? previousLastSeenItemId
+ : snapshot.lastSeenItemId,
+ composer,
+ error: message
+ };
+ });
+ if (restored && transferred) {
+ ownsObjectUrls = false;
+ turnRestored = true;
}
- }
- if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) return;
-
- const userMessage = {
- id: localMessageId,
- type: "message",
- role: "user",
- content: messageContent,
- status: "completed"
- } as unknown as Message;
-
- const stagedImageUrls = new Set();
- const staged = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => {
- for (const url of snapshot.composer.imageUrls.values()) stagedImageUrls.add(url);
- return {
+ return restored && transferred;
+ };
+
+ const markTurnIncomplete = (message: string) => {
+ runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({
...snapshot,
- messages: mergeMessagesById(snapshot.messages as Message[], [userMessage]),
- lastSeenItemId: localMessageId,
- composer: {
- ...snapshot.composer,
- input: "",
- draftImages: [],
- imageUrls: new Map(),
- documentText: "",
- documentName: "",
- isProcessingDocument: false,
- attachmentError: null,
- imagePasteGeneration: snapshot.composer.imagePasteGeneration + 1,
- documentUploadGeneration: snapshot.composer.documentUploadGeneration + 1
- }
- };
+ messages: markOptimisticMessageIncomplete(
+ snapshot.messages as Message[],
+ localMessageId
+ ),
+ lastSeenItemId: chatCursorAfterSendFailure({
+ currentCursor: snapshot.lastSeenItemId,
+ optimisticMessageId: localMessageId,
+ previousCursor: previousLastSeenItemId,
+ responseCreated: Boolean(snapshot.currentResponseId)
+ }),
+ error: message
+ }));
+ };
+
+ const unregisterCurrentTurn = registerChatCurrentTurn(runtimeStore, run.token, {
+ responseRequestStarted: () => responseRequestStarted,
+ serverRequestInFlight: () => conversationCreateInFlight,
+ restoreBeforeRequest: restoreTurn,
+ retainedPayload: item,
+ retainsPayload: () => !turnRestored,
+ countsTowardQueueLimit: true
});
- if (!staged) return;
- registerChatOptimisticMessage(runtimeStore, run.token, localMessageId);
- for (const url of stagedImageUrls) URL.revokeObjectURL(url);
-
- if (!conversationId) {
- const createParams: Parameters[0] & {
- project_id?: string;
- } = {
- metadata: {},
- ...(originalComposer.draftProjectId && {
- project_id: originalComposer.draftProjectId
- })
- };
- const newConv = await openai.conversations.create(createParams, {
- signal: run.signal
- });
- conversationId = newConv.id;
- const sourceWasSelected = isRuntimeSelected(runtimeKey);
- const destinationKey = createConversationChatKey(conversationId);
- const destinationSnapshot = runtimeStore.get(destinationKey);
- const rawRecordingOwnerKey = recordingOwnerKeyRef.current;
- const canonicalRecordingOwnerKey = rawRecordingOwnerKey
- ? runtimeStore.resolveKey(rawRecordingOwnerKey)
- : null;
- if (!canAdoptRecordingDestination(destinationKey, canonicalRecordingOwnerKey)) {
- // Let pending microphone, recording, or transcription work finish on
- // the idle destination. Adoption would make its eventual send fail.
- restoreOriginComposer(
- "This conversation is still processing a voice message. Your message was restored in its original draft."
- );
- return;
+
+ const messageContent = queuedChatMessageContent(item, () => undefined);
+ const createResponseStream = async () => {
+ if (
+ !runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({
+ ...snapshot,
+ assistantStreaming: false
+ }))
+ ) {
+ return null;
}
- if (!canAdoptAttachmentDestination(destinationSnapshot)) {
- // Let the destination's extraction callback finish on its original
- // idle runtime. Adopting it into this run would fence that callback
- // and permanently strand isProcessingDocument=true.
- restoreOriginComposer(
- "This conversation is still processing an attachment. Your message was restored in its original draft."
+ registerChatOptimisticMessage(runtimeStore, run.token, localMessageId);
+ registerUnresolvedChatResponseMessage(runtimeStore, run.token, localMessageId);
+ responseRequestStarted = true;
+ const stream = await openai.responses.create(
+ {
+ conversation: conversationId!,
+ model: item.model,
+ input: [{ role: "user", content: messageContent }],
+ metadata: { internal_message_id: localMessageId },
+ stream: true,
+ store: true,
+ ...(item.webSearchEnabled && { tools: [{ type: "web_search" }] })
+ },
+ { signal: run.signal }
+ );
+
+ if (!runtimeStore.setAssistantStreaming(runtimeKey, run.token, true)) return null;
+ try {
+ return await processStreamingResponse(
+ stream,
+ runtimeKey,
+ run.token,
+ localMessageId,
+ async (responseId) => {
+ if (!isRuntimeStopping(run.token)) return false;
+ return cancelKnownChatResponse(runtimeKey, run.token, responseId, localMessageId);
+ }
);
- return;
+ } finally {
+ runtimeStore.setAssistantStreaming(runtimeKey, run.token, false);
}
- const migration = runtimeStore.rekeyRunAdoptingIdleDestination(
- runtimeKey,
- destinationKey,
- run.token,
- (source, destination) => ({
- ...source,
- conversation: destination.conversation ?? source.conversation,
- messages: mergeLoadedMessagesWithRuntime(
- destination.messages as Message[],
- source.messages
- ),
- composer: destination.composer,
- error: source.error ?? destination.error,
- lastSeenItemId: source.lastSeenItemId ?? destination.lastSeenItemId,
- historyLoaded: source.historyLoaded || destination.historyLoaded
- })
- );
+ };
- if (migration.status === "source_stale") {
- // Creation may already be visible to another tab or device. Without
- // atomic server proof that C is empty and owned by this attempt,
- // prefer a harmless empty orphan over deleting real chat history.
- return;
+ try {
+ const dataUrls = new Map();
+ for (const file of item.draftImages) {
+ try {
+ dataUrls.set(file, await fileToDataURL(file));
+ } catch (error) {
+ console.error("Failed to convert image:", error);
+ const restored = restoreTurn(
+ "An image could not be prepared. Your message and images were restored; please try again."
+ );
+ if (!restored && runtimeStore.get(runtimeKey)) {
+ const durableContent = queuedChatMessageContent(item, (image) =>
+ dataUrls.get(image)
+ );
+ runtimeStore.update(runtimeKey, (snapshot) => ({
+ ...snapshot,
+ messages: markOptimisticMessageIncomplete(
+ updateMessageById(snapshot.messages as Message[], localMessageId, (message) =>
+ message.type === "message"
+ ? ({ ...message, content: durableContent } as unknown as Message)
+ : message
+ ),
+ localMessageId
+ )
+ }));
+ }
+ return false;
+ }
}
+ messageContent.push(
+ ...queuedChatMessageContent({ ...item, text: "", documentText: "" }, (file) =>
+ dataUrls.get(file)
+ )
+ );
- if (migration.status === "destination_active") {
- // Never replace or delete a destination that already owns a run.
- // Browser history retains this source draft, so restoring here makes
- // the original prompt and attachments recoverable with Back.
- restoreOriginComposer(
- "This conversation became active before your message was sent. Your message was restored in its original draft."
+ const updateMaterializedMessage = (messages: readonly Message[]) =>
+ updateMessageById(messages as Message[], localMessageId, (message) =>
+ message.type === "message"
+ ? ({ ...message, content: messageContent } as unknown as Message)
+ : message
);
- return;
+ if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) {
+ if (runtimeStore.get(runtimeKey)) {
+ runtimeStore.update(runtimeKey, (snapshot) => ({
+ ...snapshot,
+ messages: updateMaterializedMessage(snapshot.messages as Message[])
+ }));
+ }
+ return false;
+ }
+ if (
+ !runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({
+ ...snapshot,
+ messages: updateMaterializedMessage(snapshot.messages as Message[])
+ }))
+ ) {
+ return false;
+ }
+ if (isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) {
+ restoreTurn("Sending paused because this conversation is being deleted.");
+ return false;
}
- runtimeKey = migration.key;
- adoptedExistingDestination = migration.adoptedExistingDestination;
- runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({
- ...snapshot,
- conversation: newConv as Conversation,
- composer: { ...snapshot.composer, draftProjectId: null }
- }));
+ if (!conversationId) {
+ const createParams: Parameters[0] & {
+ project_id?: string;
+ } = {
+ metadata: {},
+ ...(item.draftProjectId && { project_id: item.draftProjectId })
+ };
+ conversationCreateInFlight = true;
+ let newConv: Awaited>;
+ try {
+ newConv = await openai.conversations.create(createParams, {
+ signal: run.signal
+ });
+ } finally {
+ conversationCreateInFlight = false;
+ }
+ conversationId = newConv.id;
+ const sourceWasSelected = isRuntimeSelected(runtimeKey);
+ const destinationKey = createConversationChatKey(conversationId);
+ const destinationSnapshot = runtimeStore.get(destinationKey);
+ const rawRecordingOwnerKey = recordingOwnerKeyRef.current;
+ const canonicalRecordingOwnerKey = rawRecordingOwnerKey
+ ? runtimeStore.resolveKey(rawRecordingOwnerKey)
+ : null;
+ if (!canAdoptRecordingDestination(destinationKey, canonicalRecordingOwnerKey)) {
+ restoreTurn(
+ "This conversation is still processing a voice message. Your message was restored in its original draft."
+ );
+ return false;
+ }
+ if (!canAdoptAttachmentDestination(destinationSnapshot)) {
+ restoreTurn(
+ "This conversation is still processing an attachment. Your message was restored in its original draft."
+ );
+ return false;
+ }
- const keepSelection = shouldProjectMigratedConversation(
- runtimeStore.isChatVisible(runtimeKey),
- sourceWasSelected,
- migration.destinationWasSelected
- );
- if (keepSelection) {
- activeRuntimeKeyRef.current = runtimeKey;
- setActiveRuntimeKey(runtimeKey);
- setChatId(conversationId);
- canonicalizeConversationHistoryEntry(conversationId);
- }
- window.dispatchEvent(new Event("conversationcreated"));
- }
+ let displacedObjectUrls: string[] = [];
+ const migration = runtimeStore.rekeyRunAdoptingIdleDestination(
+ runtimeKey,
+ destinationKey,
+ run.token,
+ (source, destination) => {
+ const mergedComposer = mergeChatComposerDraftsForRekey(
+ source.composer,
+ destination.composer,
+ destinationKey
+ );
+ displacedObjectUrls = mergedComposer.displacedObjectUrls;
+ return {
+ ...source,
+ conversation: destination.conversation ?? source.conversation,
+ messages: mergeLoadedMessagesWithRuntime(
+ destination.messages as Message[],
+ source.messages
+ ),
+ composer: mergedComposer.composer,
+ error: source.error ?? destination.error,
+ lastSeenItemId: source.lastSeenItemId ?? destination.lastSeenItemId,
+ historyLoaded: source.historyLoaded || destination.historyLoaded
+ };
+ }
+ );
- const terminalState = await createResponseStream(conversationId, isFollowUpConversation);
- completedSuccessfully = terminalState === "completed";
- scheduleBillingRefresh();
- } catch (error) {
- console.error("Failed to send message:", error);
- let errorMessage = error instanceof Error ? error.message : "Something went wrong";
- const causeMessage = (error as Error & { cause?: { message?: string } })?.cause?.message;
- if (causeMessage && causeMessage.includes("Request failed with status")) {
- errorMessage = causeMessage;
- }
+ if (migration.status === "source_stale") return false;
+ if (migration.status === "destination_active") {
+ restoreTurn(
+ "This conversation became active before your message was sent. Your message was restored in its original draft."
+ );
+ return false;
+ }
+ for (const url of displacedObjectUrls) URL.revokeObjectURL(url);
- if (isImageDescriptionUnavailableError(error)) {
- restoreOriginComposer(
- "Image description is temporarily unavailable. Your message and images were restored; please try again."
- );
- return;
- }
+ runtimeKey = migration.key;
+ runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({
+ ...snapshot,
+ conversation: newConv as Conversation,
+ composer: { ...snapshot.composer, draftProjectId: null }
+ }));
- const parseStatusError = (status: number) => {
- if (!errorMessage.includes(`Request failed with status ${status}:`)) return null;
- try {
- const jsonMatch = errorMessage.match(
- new RegExp(`Request failed with status ${status}:\\s*({.*})`)
+ const keepSelection = shouldProjectMigratedConversation(
+ runtimeStore.isChatVisible(runtimeKey),
+ sourceWasSelected,
+ migration.destinationWasSelected
);
- return jsonMatch?.[1]
- ? (JSON.parse(jsonMatch[1]) as { status: number; message: string })
- : null;
- } catch (parseError) {
- console.error(`Failed to parse ${status} error:`, parseError);
- return null;
+ if (keepSelection) {
+ activeRuntimeKeyRef.current = runtimeKey;
+ setActiveRuntimeKey(runtimeKey);
+ setChatId(conversationId);
+ canonicalizeConversationHistoryEntry(conversationId);
+ }
+ window.dispatchEvent(new Event("conversationcreated"));
}
- };
- const status413Error = parseStatusError(413);
- if (status413Error && status413Error.message === "Message exceeds context limit") {
- restoreOriginComposer("Your message exceeds the context limit for this model.");
- if (isRuntimeSelected(runtimeKey)) setContextLimitDialogOpen(true);
- return;
- }
+ if (isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) {
+ restoreTurn("Sending paused because this conversation is being deleted.");
+ return false;
+ }
- const status403Error = parseStatusError(403);
- if (status403Error) {
- let displayError: string;
- if (status403Error.message === "Free tier token limit exceeded") {
- displayError =
- "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.";
- if (isRuntimeSelected(runtimeKey)) {
- setUpgradeFeature("tokens");
- setUpgradeDialogOpen(true);
+ const terminalState = await createResponseStream();
+ scheduleBillingRefresh();
+ return terminalState === "completed";
+ } catch (error) {
+ console.error("Failed to send message:", error);
+ let errorMessage = error instanceof Error ? error.message : "Something went wrong";
+ const causeMessage = (error as Error & { cause?: { message?: string } })?.cause?.message;
+ if (causeMessage?.includes("Request failed with status")) errorMessage = causeMessage;
+
+ const retainAmbiguousResponseOwnership = (message: string) => {
+ markTurnIncomplete(message);
+ preserveUnresolvedResponseOwnership = !runtimeStore.get(runtimeKey)?.currentResponseId;
+ detachedResponseOwnsSettlement = true;
+ };
+
+ if (isChatAccountCredentialMismatchError(error)) {
+ if (!responseRequestStarted || isChatRequestDefinitelyNotDispatchedError(error)) {
+ restoreTurn(
+ "Sending paused because the authenticated account changed. Your message was restored."
+ );
+ } else {
+ retainAmbiguousResponseOwnership(
+ "The authenticated account changed after sending began. The response may still be running; queued messages were kept."
+ );
}
- } else if (status403Error.message === "Usage limit reached") {
- const isFreeTier =
- !billingStatusAtSend?.product_name ||
- billingStatusAtSend.product_name.toLowerCase() === "free";
+ return false;
+ }
+
+ if (isImageDescriptionUnavailableError(error)) {
+ restoreTurn(
+ "Image description is temporarily unavailable. Your message and images were restored; please try again."
+ );
+ return false;
+ }
- if (isFreeTier) {
+ const parseStatusError = (status: number) => {
+ if (!errorMessage.includes(`Request failed with status ${status}:`)) return null;
+ try {
+ const jsonMatch = errorMessage.match(
+ new RegExp(`Request failed with status ${status}:\\s*({.*})`)
+ );
+ return jsonMatch?.[1]
+ ? (JSON.parse(jsonMatch[1]) as { status: number; message: string })
+ : null;
+ } catch (parseError) {
+ console.error(`Failed to parse ${status} error:`, parseError);
+ return null;
+ }
+ };
+
+ const status413Error = parseStatusError(413);
+ if (status413Error?.message === "Message exceeds context limit") {
+ restoreTurn("Your message exceeds the context limit for this model.");
+ if (isRuntimeSelected(runtimeKey)) setContextLimitDialogOpen(true);
+ return false;
+ }
+
+ const status403Error = parseStatusError(403);
+ if (status403Error) {
+ let displayError: string;
+ if (status403Error.message === "Free tier token limit exceeded") {
displayError =
- "You've reached your daily usage limit. Upgrade to Pro for more chats.";
+ "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.";
+ if (isRuntimeSelected(runtimeKey)) {
+ setUpgradeFeature("tokens");
+ setUpgradeDialogOpen(true);
+ }
+ } else if (status403Error.message === "Usage limit reached") {
+ const isFreeTier =
+ !billingStatus?.product_name || billingStatus.product_name.toLowerCase() === "free";
+ if (isFreeTier) {
+ displayError =
+ "You've reached your daily usage limit. Upgrade to Pro for more chats.";
+ } else {
+ const isPro =
+ billingStatus.product_name?.toLowerCase().includes("pro") &&
+ !billingStatus.product_name?.toLowerCase().includes("max");
+ displayError = isPro
+ ? "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage."
+ : "You've reached your monthly usage limit. Please wait for the next billing cycle.";
+ }
+ if (isRuntimeSelected(runtimeKey)) {
+ setUpgradeFeature("usage");
+ setUpgradeDialogOpen(true);
+ }
} else {
- const isPro =
- billingStatusAtSend.product_name?.toLowerCase().includes("pro") &&
- !billingStatusAtSend.product_name?.toLowerCase().includes("max");
- displayError = isPro
- ? "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage."
- : "You've reached your monthly usage limit. Please wait for the next billing cycle.";
+ displayError =
+ status403Error.message || "Access denied. Please check your subscription.";
}
- if (isRuntimeSelected(runtimeKey)) {
- setUpgradeFeature("usage");
- setUpgradeDialogOpen(true);
+ restoreTurn(displayError);
+ return false;
+ }
+
+ if (
+ isChatRequestDefinitelyNotDispatchedError(error) ||
+ isChatResponseDefinitelyRejectedError(error)
+ ) {
+ restoreTurn(`${errorMessage}. Please try again.`);
+ return false;
+ }
+
+ if (error instanceof Error && error.name !== "AbortError") {
+ if (!responseRequestStarted) {
+ restoreTurn(`${errorMessage}. Please try again.`);
+ } else {
+ // Once an ambiguous streaming POST has started, replaying it can
+ // create a duplicate provider turn. Keep later FIFO items staged
+ // and retain known server ownership until polling or Stop reaches
+ // a terminal response state.
+ retainAmbiguousResponseOwnership(
+ `${errorMessage}. The response may still be running; queued messages were kept.`
+ );
}
- } else {
- displayError =
- status403Error.message || "Access denied. Please check your subscription.";
}
- restoreOriginComposer(displayError);
- } else if (error instanceof Error && error.name !== "AbortError") {
- if (isFollowUpConversation && conversationId) {
- try {
- console.log("Waiting 1s before retry...");
- await new Promise((resolve) => setTimeout(resolve, 1000));
- if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) return;
-
- console.log("Retrying request once...");
- const terminalState = await createResponseStream(conversationId, false);
- completedSuccessfully = terminalState === "completed";
- scheduleBillingRefresh();
- console.log("Retry completed successfully");
- return;
- } catch (retryError) {
- console.error("Retry failed:", retryError);
- if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) return;
-
- try {
- const finalCheckResponse = await openai.conversations.items.list(conversationId, {
- limit: 5,
- order: "desc"
- });
- const foundMessage = finalCheckResponse.data.find(
- (item) => item.id === localMessageId
- );
+ return false;
+ } finally {
+ unregisterCurrentTurn();
+ unregisterChatOptimisticMessage(runtimeStore, run.token, localMessageId);
+ if (!preserveUnresolvedResponseOwnership) {
+ clearUnresolvedChatResponseMessage(runtimeStore, run.token, localMessageId);
+ }
+ releaseObjectUrls();
+ }
+ };
- if (!foundMessage) {
- console.log("Message not found after retry - restoring input");
- restoreOriginComposer("Failed to send message. Please try again.");
- } else {
- console.log("Message found after retry failure - it actually went through");
- }
- } catch (finalCheckError) {
- console.error("Final check failed:", finalCheckError);
- restoreOriginComposer("Failed to send message. Please try again.");
+ try {
+ while (currentTurn && runtimeStore.isRunCurrent(runtimeKey, run.token)) {
+ const completed = await sendTurn(currentTurn);
+ if (!completed || !runtimeStore.isRunCurrent(runtimeKey, run.token)) {
+ if (runtimeStore.isRunCurrent(runtimeKey, run.token) && isRuntimeStopping(run.token)) {
+ const responseId = runtimeStore.get(runtimeKey)?.currentResponseId;
+ stopOwnsSettlement = Boolean(responseId);
+ if (!responseId) {
+ // A failed POST before response.created can still be accepted
+ // server-side. Keep the run and Stop intent fenced until item
+ // polling recovers its response UUID; never overlap the FIFO.
+ setErrorForKey(
+ runtimeKey,
+ "Maple could not confirm that the response stopped. Queued messages were kept."
+ );
}
}
- } else {
- const optimisticMessageId = getRegisteredChatOptimisticMessage(runtimeStore, run.token);
- runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => ({
+ break;
+ }
+ completedAnyTurn = true;
+ if (isChatRunQueueHaltRequested(runtimeStore, run.token)) {
+ if (!runtimeStore.get(runtimeKey)?.currentResponseId) {
+ stoppingRuntimeRegistry.delete(runtimeKey, run.token);
+ }
+ break;
+ }
+ if (isRuntimeStopping(run.token)) break;
+ if (isChatRuntimeDeletionPending(runtimeStore, runtimeKey)) break;
+
+ let nextTurn: ChatQueueTurn | undefined;
+ const advanced = runtimeStore.updateForRun(runtimeKey, run.token, (snapshot) => {
+ const next = takeNextChatQueuedMessage(snapshot.composer.queue);
+ if (next.status !== "taken") {
+ return {
+ ...snapshot,
+ currentResponseId: undefined,
+ assistantStreaming: false
+ };
+ }
+ nextTurn = {
+ item: next.item,
+ recoverOnFailure: false,
+ previousLastSeenItemId: snapshot.lastSeenItemId
+ };
+ return {
...snapshot,
- messages: markOptimisticMessageIncomplete(
- snapshot.messages as Message[],
- optimisticMessageId
- ),
- error: `${errorMessage}. Please try again.`
- }));
+ composer: { ...snapshot.composer, queue: next.queue },
+ messages: mergeMessagesById(snapshot.messages as Message[], [
+ promotedChatUserMessage(next.item)
+ ]),
+ lastSeenItemId: next.item.messageId,
+ currentResponseId: undefined,
+ assistantStreaming: false,
+ error: null
+ };
+ });
+ if (!advanced) break;
+ if (!nextTurn) {
+ break;
}
+ currentTurn = nextTurn;
+ registerChatOptimisticMessage(runtimeStore, run.token, currentTurn.item.messageId);
}
} finally {
- unregisterChatOptimisticMessage(runtimeStore, run.token, localMessageId);
- if (completedSuccessfully) {
- runtimeStore.completeRun(runtimeKey, run.token);
- } else {
- runtimeStore.finishRun(runtimeKey, run.token);
+ if (
+ runtimeStore.isRunCurrent(runtimeKey, run.token) &&
+ !stopOwnsSettlement &&
+ !detachedResponseOwnsSettlement
+ ) {
+ if (completedAnyTurn) runtimeStore.completeRun(runtimeKey, run.token);
+ else runtimeStore.finishRun(runtimeKey, run.token);
+ }
+ if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) {
+ clearUnresolvedChatResponseMessage(runtimeStore, run.token);
+ }
+ if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) {
+ stoppingRuntimeRegistry.delete(runtimeKey, run.token);
+ }
+ if (!runtimeStore.isRunCurrent(runtimeKey, run.token)) {
+ clearChatRunQueueHalt(runtimeStore, run.token);
}
}
},
[
billingRefreshTimeoutsRef,
billingStatus,
+ cancelKnownChatResponse,
isRuntimeSelected,
+ isCompactLayout,
isWebSearchEnabled,
model,
openai,
processStreamingResponse,
queryClient,
runtimeStore,
- selectedProjectId
+ selectedProjectId,
+ setErrorForKey,
+ stoppingRuntimeRegistry
]
);
@@ -4785,6 +5621,11 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
// On desktop: Enter submits, Shift+Enter for new line
// On mobile: Enter for new line, no keyboard shortcut to submit (use button)
if (e.nativeEvent.isComposing) return;
+ if (e.key === "Escape" && queueEdit) {
+ e.preventDefault();
+ discardQueueEdit();
+ return;
+ }
if ((e.shiftKey || isCompactLayout) && continueChatComposerList(e, setInput)) {
return;
}
@@ -4983,7 +5824,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {