Unverified 提交 11b33efe authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Allow users to queue messages (#2120)

<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Allow users to queue multiple messages while a response is streaming. Queued items are visible, editable, reorderable, and auto-send one-by-one only after the current stream ends successfully. - **New Features** - Per-chat queue state via queuedMessagesByIdAtom and streamCompletedSuccessfullyByIdAtom. - QueuedMessagesList to view, edit, reorder, and delete items; shows status and attachment indicator. - ChatInput queues while streaming, supports editing queued items, clears input/attachments only on successful queue/send, and clears all queued on cancel. - useStreamChat exposes queuedMessages and queue/update/remove/reorder/clear APIs; processes the next item only after a confirmed successful end. - **Bug Fixes** - Eliminated race conditions by gating processing on successful onEnd (wasCancelled-aware), fixing a stale firstMessage closure, and clearing the queue before cancel IPC to avoid rapid-response races. - Preserved input and attachments when queueMessage fails; prevented dropping newly queued messages; cleared editing state when the edited queued item is deleted. <sup>Written for commit fb431f55b3a24284058062a6ced97ac21f825952. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces queued message support so users can submit another prompt while a response is streaming; the queued prompt auto-sends only after the current stream ends successfully. > > - Adds `QueuedMessage`, `queuedMessageByIdAtom`, and `streamCompletedSuccessfullyByIdAtom` to track one queued message and confirmed stream completion per chat > - Extends `useStreamChat` with queue APIs (`queuedMessage`, `queueMessage`, `clearQueuedMessage`) and processing gated by `onEnd` success; resets/sets completion flags; warns on multiple queue attempts > - Updates `ChatInput` to queue while streaming, clear on successful queue, and clear queued message on cancel; uses `shouldProcessQueue` > - Updates `MessagesList` to display queued prompt with status and a Clear action > - Expands IPC `ChatResponseEnd` schema with optional `wasCancelled`; send this flag on cancel in `chat_stream_handlers` > - Adds e2e test `queued_message.spec.ts` verifying queuing and auto-send behavior > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 62ed2131f7f9ee253b2542d35307459af504d6d0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2120"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com>
上级 a3758157
import { test } from "./helpers/test_helper";
import { expect, Locator } from "@playwright/test";
test.describe("queued messages", () => {
let chatInput: Locator;
test.beforeEach(async ({ po }) => {
await po.setUp();
chatInput = po.chatActions.getChatInput();
});
test("gets added and sent after stream completes", async ({ po }) => {
// Send a message with a medium sleep to simulate a slow response
await po.sendPrompt("tc=1 [sleep=medium]", {
skipWaitForCompletion: true,
});
// Wait for chat input to appear (indicates we're in chat view and streaming)
await expect(chatInput).toBeVisible();
// While streaming, send another message - this should be queued
await chatInput.fill("tc=2");
await chatInput.press("Enter");
// Verify the queued message indicator is visible
// The UI shows "{count} Queued" followed by "- {status}"
await expect(
po.page.getByText(/\d+ Queued.*will send after current response/),
).toBeVisible();
// Wait for the first stream to complete
await po.chatActions.waitForChatCompletion();
// Verify the queued message indicator is gone (message is now being sent)
await expect(
po.page.getByText(/\d+ Queued.*will send after current response/),
).not.toBeVisible();
// Wait for the queued message to also complete
await po.chatActions.waitForChatCompletion();
// Verify both messages were sent by checking the message list
const messagesList = po.page.locator('[data-testid="messages-list"]');
await expect(messagesList.getByText("tc=1 [sleep=medium]")).toBeVisible();
await expect(messagesList.getByText("tc=2")).toBeVisible();
});
test("can be reordered, deleted, and edited", async ({ po }) => {
// Send a message with a medium sleep to simulate a slow response
await po.sendPrompt("tc=1 [sleep=medium]", {
skipWaitForCompletion: true,
});
// Wait for chat input to appear (indicates we're in chat view and streaming)
await expect(chatInput).toBeVisible();
// Queue 3 messages while streaming
await chatInput.fill("tc=first");
await chatInput.press("Enter");
await chatInput.fill("tc=second");
await chatInput.press("Enter");
await chatInput.fill("tc=third");
await chatInput.press("Enter");
// Verify 3 messages are queued
await expect(po.page.getByText("3 Queued")).toBeVisible();
// Reorder: move "tc=third" up so it swaps with "tc=second"
const thirdRow = po.page.locator("li", { hasText: "tc=third" });
await thirdRow.hover();
await thirdRow.getByTitle("Move up").click();
// Delete: remove "tc=second" (now the last item after the reorder)
const secondRow = po.page.locator("li", { hasText: "tc=second" });
await secondRow.hover();
await secondRow.getByTitle("Delete").click();
// Verify count dropped to 2
await expect(po.page.getByText("2 Queued")).toBeVisible();
// Edit: click edit on "tc=first", modify the text, and submit
const firstRow = po.page.locator("li", { hasText: "tc=first" });
await firstRow.hover();
await firstRow.getByTitle("Edit").click();
// The input should now contain the message text
await expect(chatInput).toContainText("tc=first");
// Clear and type the new text
await chatInput.click();
await po.page.keyboard.press("ControlOrMeta+a");
await chatInput.pressSequentially("tc=first-edited");
await chatInput.press("Enter");
// Verify the edited text appears in the queue
await expect(
po.page.locator("li", { hasText: "tc=first-edited" }),
).toBeVisible();
// Wait for the initial stream to finish, then the queued messages to send
await po.chatActions.waitForChatCompletion();
await po.chatActions.waitForChatCompletion();
// Verify the final messages were sent in correct order:
// "tc=first-edited" first, then "tc=third" (which was moved up past "tc=second")
const messagesList = po.page.locator('[data-testid="messages-list"]');
await expect(messagesList.getByText("tc=first-edited")).toBeVisible();
await expect(messagesList.getByText("tc=third")).toBeVisible();
// "tc=second" was deleted, so it should NOT appear
await expect(messagesList.getByText("tc=second")).not.toBeVisible();
});
});
import type { FileAttachment, Message, AgentTodo } from "@/ipc/types";
import type {
FileAttachment,
Message,
AgentTodo,
ComponentSelection,
} from "@/ipc/types";
import type { Getter, Setter } from "jotai";
import { atom } from "jotai";
......@@ -207,3 +212,22 @@ export const agentTodosByChatIdAtom = atom<Map<number, AgentTodo[]>>(new Map());
// Flag: set when user switches to plan mode from another mode in a chat with messages
export const needsFreshPlanChatAtom = atom<boolean>(false);
// Queued messages (multiple messages per chat, sent in sequence after streams complete)
export interface QueuedMessageItem {
id: string; // UUID for stable identification during reordering/editing
prompt: string;
attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[];
}
// Map<chatId, QueuedMessageItem[]>
export const queuedMessagesByIdAtom = atom<Map<number, QueuedMessageItem[]>>(
new Map(),
);
// Tracks whether the last stream for a chat completed successfully (via onEnd, not cancelled or errored)
// This is used to safely process the queue only when we're certain the stream finished normally
export const streamCompletedSuccessfullyByIdAtom = atom<Map<number, boolean>>(
new Map(),
);
......@@ -63,6 +63,7 @@ import { ChatErrorBox } from "./ChatErrorBox";
import { AgentConsentBanner } from "./AgentConsentBanner";
import { TodoList } from "./TodoList";
import { QuestionnaireInput } from "./QuestionnaireInput";
import { QueuedMessagesList } from "./QueuedMessagesList";
import {
selectedComponentsPreviewAtom,
previewIframeRefAtom,
......@@ -103,11 +104,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const { settings } = useSettings();
const appId = useAtomValue(selectedAppIdAtom);
const { refreshVersions } = useVersions(appId);
const { streamMessage, isStreaming, setIsStreaming, error, setError } =
useStreamChat();
const {
streamMessage,
isStreaming,
setIsStreaming,
error,
setError,
queuedMessages,
queueMessage,
updateQueuedMessage,
removeQueuedMessage,
reorderQueuedMessages,
clearAllQueuedMessages,
} = useStreamChat({ shouldProcessQueue: true });
const [showError, setShowError] = useState(true);
const [isApproving, setIsApproving] = useState(false); // State for approving
const [isRejecting, setIsRejecting] = useState(false); // State for rejecting
const [editingQueuedMessageId, setEditingQueuedMessageId] = useState<
string | null
>(null);
const messagesById = useAtomValue(chatMessagesByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
......@@ -242,10 +257,54 @@ export function ChatInput({ chatId }: { chatId?: number }) {
});
}, [chatId, setMessagesById]);
// Queue management handlers
const handleEditQueuedMessage = useCallback(
(id: string) => {
const msg = queuedMessages.find((m) => m.id === id);
if (!msg) return;
// Load the message content into the input
setInputValue(msg.prompt);
// Set editing mode
setEditingQueuedMessageId(id);
},
[queuedMessages, setInputValue],
);
const handleMoveUp = useCallback(
(id: string) => {
const index = queuedMessages.findIndex((m) => m.id === id);
if (index > 0) {
reorderQueuedMessages(index, index - 1);
}
},
[queuedMessages, reorderQueuedMessages],
);
const handleMoveDown = useCallback(
(id: string) => {
const index = queuedMessages.findIndex((m) => m.id === id);
if (index >= 0 && index < queuedMessages.length - 1) {
reorderQueuedMessages(index, index + 1);
}
},
[queuedMessages, reorderQueuedMessages],
);
const handleDeleteQueuedMessage = useCallback(
(id: string) => {
// Clear editing state if deleting the message being edited
if (editingQueuedMessageId === id) {
setEditingQueuedMessageId(null);
setInputValue("");
}
removeQueuedMessage(id);
},
[editingQueuedMessageId, removeQueuedMessage, setInputValue],
);
const handleSubmit = async () => {
if (
(!inputValue.trim() && attachments.length === 0) ||
isStreaming ||
!chatId ||
pendingFiles
) {
......@@ -277,13 +336,51 @@ export function ChatInput({ chatId }: { chatId?: number }) {
}
const currentInput = inputValue;
setInputValue("");
// Use all selected components for multi-component editing
const componentsToSend =
selectedComponents && selectedComponents.length > 0
? selectedComponents
: [];
// Handle editing a queued message
if (editingQueuedMessageId) {
updateQueuedMessage(editingQueuedMessageId, {
prompt: currentInput,
});
setInputValue("");
setEditingQueuedMessageId(null);
return;
}
// If streaming, queue the message instead of sending immediately
if (isStreaming) {
const queued = queueMessage({
prompt: currentInput,
attachments,
selectedComponents: componentsToSend,
});
if (queued) {
// Only clear input, attachments, and components on successful queue
setInputValue("");
clearAttachments();
setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
// Clear overlays in the preview iframe
if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage(
{ type: "clear-dyad-component-overlays" },
"*",
);
}
}
// If queue failed, leave input/attachments intact for the user
return;
}
// Not streaming - send immediately
// Clear input and components before sending
setInputValue("");
setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
// Clear overlays in the preview iframe
......@@ -307,6 +404,16 @@ export function ChatInput({ chatId }: { chatId?: number }) {
};
const handleCancel = () => {
// Clear all queued messages first, BEFORE the IPC call, to ensure
// the queue is empty even if the backend response arrives quickly.
// This prevents race conditions where the queue-processing effect
// could potentially run if the backend responds before queue clearing.
clearAllQueuedMessages();
// Reset editing state so the "Editing queued message" banner is dismissed
if (editingQueuedMessageId) {
setEditingQueuedMessageId(null);
setInputValue("");
}
if (chatId) {
ipc.chat.cancelStream(chatId);
}
......@@ -480,6 +587,36 @@ export function ChatInput({ chatId }: { chatId?: number }) {
}}
/>
)}
{/* Show queued messages list */}
{queuedMessages.length > 0 && (
<QueuedMessagesList
messages={queuedMessages}
onEdit={handleEditQueuedMessage}
onDelete={handleDeleteQueuedMessage}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
isStreaming={isStreaming}
hasError={!!error}
/>
)}
{/* Show editing indicator when editing a queued message */}
{editingQueuedMessageId && (
<div className="border-b border-border p-2 bg-yellow-500/10 flex items-center justify-between">
<span className="text-sm text-yellow-700 dark:text-yellow-400">
Editing queued message
</span>
<button
type="button"
onClick={() => {
setEditingQueuedMessageId(null);
setInputValue("");
}}
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer"
>
Cancel
</button>
</div>
)}
{/* Only render ChatInputActions if proposal is loaded and no pending consent */}
{!pendingAgentConsent &&
proposal &&
......
import React, { useState } from "react";
import type { QueuedMessageItem } from "@/atoms/chatAtoms";
import {
ChevronDown,
ChevronUp,
ListOrdered,
Pencil,
Trash2,
ArrowUp,
ArrowDown,
Paperclip,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface QueuedMessagesListProps {
messages: QueuedMessageItem[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
isStreaming: boolean;
hasError: boolean;
}
interface QueuedMessageItemRowProps {
message: QueuedMessageItem;
index: number;
total: number;
onEdit: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function QueuedMessageItemRow({
message,
index,
total,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
}: QueuedMessageItemRowProps) {
return (
<li className="flex items-center gap-2 text-sm py-1.5 px-2 bg-muted/50 rounded group">
{/* Message preview */}
<span className="flex-1 truncate">{message.prompt}</span>
{/* Attachment indicator if present */}
{message.attachments && message.attachments.length > 0 && (
<Paperclip size={14} className="text-muted-foreground flex-shrink-0" />
)}
{/* Action buttons - visible on hover */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={onEdit}
className="p-1 hover:bg-muted rounded cursor-pointer"
title="Edit"
>
<Pencil size={14} className="text-muted-foreground" />
</button>
<button
type="button"
onClick={onMoveUp}
disabled={index === 0}
className={cn(
"p-1 hover:bg-muted rounded cursor-pointer",
index === 0 && "opacity-30 cursor-not-allowed",
)}
title="Move up"
>
<ArrowUp size={14} className="text-muted-foreground" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={index === total - 1}
className={cn(
"p-1 hover:bg-muted rounded cursor-pointer",
index === total - 1 && "opacity-30 cursor-not-allowed",
)}
title="Move down"
>
<ArrowDown size={14} className="text-muted-foreground" />
</button>
<button
type="button"
onClick={onDelete}
className="p-1 hover:bg-muted rounded cursor-pointer"
title="Delete"
>
<Trash2 size={14} className="text-red-500" />
</button>
</div>
</li>
);
}
export function QueuedMessagesList({
messages,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
isStreaming,
hasError,
}: QueuedMessagesListProps) {
const [isExpanded, setIsExpanded] = useState(true);
if (!messages.length) return null;
const statusText = hasError
? "will send after a successful response"
: isStreaming
? "will send after current response"
: "ready to send";
return (
<div className="border-b border-border bg-muted/30">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<ListOrdered className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm">{messages.length} Queued</span>
<span className="text-xs text-muted-foreground">- {statusText}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</div>
</button>
<div
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{
gridTemplateRows: isExpanded ? "1fr" : "0fr",
}}
>
<div className="overflow-hidden">
<ul className="px-3 pb-2.5 space-y-1.5">
{messages.map((msg, index) => (
<QueuedMessageItemRow
key={msg.id}
message={msg}
index={index}
total={messages.length}
onEdit={() => onEdit(msg.id)}
onDelete={() => onDelete(msg.id)}
onMoveUp={() => onMoveUp(msg.id)}
onMoveDown={() => onMoveDown(msg.id)}
/>
))}
</ul>
</div>
</div>
</div>
);
}
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import type {
ComponentSelection,
FileAttachment,
......@@ -11,6 +11,9 @@ import {
chatStreamCountByIdAtom,
isStreamingByIdAtom,
recentStreamChatIdsAtom,
queuedMessagesByIdAtom,
streamCompletedSuccessfullyByIdAtom,
type QueuedMessageItem,
} from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
......@@ -41,7 +44,8 @@ const pendingStreamChatIds = new Set<number>();
export function useStreamChat({
hasChatId = true,
}: { hasChatId?: boolean } = {}) {
shouldProcessQueue = false,
}: { hasChatId?: boolean; shouldProcessQueue?: boolean } = {}) {
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
......@@ -59,6 +63,12 @@ export function useStreamChat({
const { checkProblems } = useCheckProblems(selectedAppId);
const { settings } = useSettings();
const setRecentStreamChatIds = useSetAtom(recentStreamChatIdsAtom);
const [queuedMessagesById, setQueuedMessagesById] = useAtom(
queuedMessagesByIdAtom,
);
const [streamCompletedSuccessfullyById, setStreamCompletedSuccessfullyById] =
useAtom(streamCompletedSuccessfullyByIdAtom);
const posthog = usePostHog();
const queryClient = useQueryClient();
let chatId: number | undefined;
......@@ -121,6 +131,12 @@ export function useStreamChat({
next.set(chatId, true);
return next;
});
// Reset the successful completion flag when starting a new stream
setStreamCompletedSuccessfullyById((prev) => {
const next = new Map(prev);
next.set(chatId, false);
return next;
});
// Convert FileAttachment[] (with File objects) to ChatAttachment[] (base64 encoded)
let convertedAttachments: ChatAttachment[] | undefined;
......@@ -175,6 +191,15 @@ export function useStreamChat({
onEnd: (response: ChatResponseEnd) => {
// Remove from pending set now that stream is complete
pendingStreamChatIds.delete(chatId);
// Only mark as successful if NOT cancelled - wasCancelled flag is set
// by the backend when user cancels the stream
if (!response.wasCancelled) {
setStreamCompletedSuccessfullyById((prev) => {
const next = new Map(prev);
next.set(chatId, true);
return next;
});
}
// Show native notification if enabled and window is not focused
// Fire-and-forget to avoid blocking UI updates
......@@ -303,6 +328,7 @@ export function useStreamChat({
setMessagesById,
setIsStreamingById,
setIsPreviewOpen,
setStreamCompletedSuccessfullyById,
checkProblems,
selectedAppId,
refetchUserBudget,
......@@ -311,6 +337,66 @@ export function useStreamChat({
],
);
// Process first queued message when streaming ends successfully
useEffect(() => {
if (!chatId || !shouldProcessQueue) return;
const queuedMessages = queuedMessagesById.get(chatId) ?? [];
const completedSuccessfully =
streamCompletedSuccessfullyById.get(chatId) ?? false;
// Only process queue if we have confirmation that the stream completed successfully
// This prevents race conditions where the queue might be processed during cancellation
if (queuedMessages.length > 0 && completedSuccessfully) {
// Clear the successful completion flag first to prevent loops
setStreamCompletedSuccessfullyById((prev) => {
const next = new Map(prev);
next.set(chatId, false);
return next;
});
// Get and remove the first message atomically by extracting it inside the setter
// This prevents race conditions where the queue might be modified between
// reading firstMessage and updating the queue
let messageToSend: QueuedMessageItem | undefined;
setQueuedMessagesById((prev) => {
const next = new Map(prev);
const current = prev.get(chatId) ?? [];
const [first, ...remainingMessages] = current;
messageToSend = first;
if (remainingMessages.length > 0) {
next.set(chatId, remainingMessages);
} else {
next.delete(chatId);
}
return next;
});
if (!messageToSend) return;
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode });
// Send the first message
streamMessage({
prompt: messageToSend.prompt,
chatId,
redo: false,
attachments: messageToSend.attachments,
selectedComponents: messageToSend.selectedComponents,
});
}
}, [
chatId,
queuedMessagesById,
streamCompletedSuccessfullyById,
streamMessage,
setQueuedMessagesById,
setStreamCompletedSuccessfullyById,
posthog,
settings?.selectedChatMode,
shouldProcessQueue,
]);
return {
streamMessage,
isStreaming:
......@@ -333,5 +419,82 @@ export function useStreamChat({
if (chatId !== undefined) next.set(chatId, value);
return next;
}),
// Multi-message queue support
queuedMessages:
hasChatId && chatId !== undefined
? (queuedMessagesById.get(chatId) ?? [])
: [],
queueMessage: (message: Omit<QueuedMessageItem, "id">): boolean => {
if (chatId === undefined) return false;
const newItem: QueuedMessageItem = {
...message,
id: crypto.randomUUID(),
};
setQueuedMessagesById((prev) => {
const next = new Map(prev);
const existing = prev.get(chatId) ?? [];
next.set(chatId, [...existing, newItem]);
return next;
});
return true;
},
updateQueuedMessage: (
id: string,
updates: Partial<
Pick<QueuedMessageItem, "prompt" | "attachments" | "selectedComponents">
>,
) => {
if (chatId === undefined) return;
setQueuedMessagesById((prev) => {
const next = new Map(prev);
const existing = prev.get(chatId) ?? [];
const updated = existing.map((msg) =>
msg.id === id ? { ...msg, ...updates } : msg,
);
next.set(chatId, updated);
return next;
});
},
removeQueuedMessage: (id: string) => {
if (chatId === undefined) return;
setQueuedMessagesById((prev) => {
const next = new Map(prev);
const existing = prev.get(chatId) ?? [];
const filtered = existing.filter((msg) => msg.id !== id);
if (filtered.length > 0) {
next.set(chatId, filtered);
} else {
next.delete(chatId);
}
return next;
});
},
reorderQueuedMessages: (fromIndex: number, toIndex: number) => {
if (chatId === undefined) return;
setQueuedMessagesById((prev) => {
const next = new Map(prev);
const existing = [...(prev.get(chatId) ?? [])];
if (
fromIndex < 0 ||
fromIndex >= existing.length ||
toIndex < 0 ||
toIndex >= existing.length
) {
return prev;
}
const [removed] = existing.splice(fromIndex, 1);
existing.splice(toIndex, 0, removed);
next.set(chatId, existing);
return next;
});
},
clearAllQueuedMessages: () => {
if (chatId === undefined) return;
setQueuedMessagesById((prev) => {
const next = new Map(prev);
next.delete(chatId);
return next;
});
},
};
}
......@@ -1694,10 +1694,11 @@ ${problemReport.problems
logger.warn(`No active stream found for chat ${chatId}`);
}
// Send the end event to the renderer
// Send the end event to the renderer with wasCancelled flag
safeSend(event.sender, "chat:response:end", {
chatId,
updatedFiles: false,
wasCancelled: true,
} satisfies ChatResponseEnd);
// Also emit stream:end so cleanup listeners (e.g., pending agent consents) fire
......
......@@ -109,6 +109,8 @@ export const ChatResponseEndSchema = z.object({
totalTokens: z.number().optional(),
contextWindow: z.number().optional(),
chatSummary: z.string().optional(),
/** Indicates the stream was cancelled by the user, not completed successfully */
wasCancelled: z.boolean().optional(),
});
export type ChatResponseEnd = z.infer<typeof ChatResponseEndSchema>;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论