Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -860,10 +860,10 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {

const handleEditQueuedMessage = useCallback(async () => {
const queuedMessage = workspaceState?.queuedMessage;
if (!queuedMessage) return;
if (!queuedMessage || workspaceState?.isStreamStarting) return;

await restoreQueuedDraft(queuedMessage);
}, [restoreQueuedDraft, workspaceState?.queuedMessage]);
}, [restoreQueuedDraft, workspaceState?.isStreamStarting, workspaceState?.queuedMessage]);

const sendQueuedImmediatelyInFlightRef = useRef<string | null>(null);

Expand Down Expand Up @@ -958,7 +958,9 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
if (!current) return;

if (current.queuedMessage) {
await restoreQueuedDraft(current.queuedMessage);
if (!current.isStreamStarting) {
await restoreQueuedDraft(current.queuedMessage);
}
return;
}

Expand Down Expand Up @@ -1818,6 +1820,7 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
node: (
<QueuedMessage
message={props.queuedMessage}
isDispatching={props.isStreamStarting}
Comment thread
ammar-agent marked this conversation as resolved.
Outdated
onEdit={() => void props.onEditQueuedMessage()}
onChangeDispatchMode={props.onQueuedDispatchModeChange}
onActionError={props.onQueuedActionError}
Expand Down
1 change: 1 addition & 0 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3356,6 +3356,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
variant === "workspace" &&
!editingMessageForUi &&
props.queuedMessage != null &&
!isStreamStarting &&
input.trim() === "" &&
attachments.length === 0 &&
reviewPanelItems.length === 0;
Expand Down
25 changes: 16 additions & 9 deletions src/browser/features/Messages/QueuedMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { cn } from "@/common/lib/utils";

interface QueuedMessageProps {
message: QueuedMessageType;
isDispatching?: boolean;
className?: string;
onEdit?: () => void;
onChangeDispatchMode?: (mode: QueueDispatchMode) => Promise<void>;
Expand Down Expand Up @@ -44,6 +45,7 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = (props) => {
const queueDispatchMode = props.message.queueDispatchMode ?? "tool-end";
const queueStatusLabel =
queueDispatchMode === "turn-end" ? "Sends after this turn" : "Sends after this step";
const isDispatching = props.isDispatching === true;
const isActionPending = pendingAction != null;

const handleDispatchModeChange = (mode: QueueDispatchMode) => {
Expand Down Expand Up @@ -114,7 +116,7 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = (props) => {
className="mt-1.5 flex max-w-full flex-wrap items-center justify-end gap-1 text-[11px]"
data-component="QueuedMessageActions"
>
{props.onEdit && (
{!isDispatching && props.onEdit && (
<button
type="button"
onClick={props.onEdit}
Expand All @@ -141,23 +143,28 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = (props) => {
>
<button
type="button"
onClick={() => setIsMenuOpen((open) => !open)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
className="text-secondary bg-muted/10 hover:bg-hover hover:text-foreground flex h-6 max-w-full items-center gap-1.5 rounded-md px-2 font-medium transition-colors"
onClick={() => {
if (!isDispatching) setIsMenuOpen((open) => !open);
}}
aria-haspopup={isDispatching ? undefined : "menu"}
aria-expanded={isDispatching ? undefined : isMenuOpen}
disabled={isDispatching}
className="text-secondary bg-muted/10 hover:bg-hover hover:text-foreground flex h-6 max-w-full items-center gap-1.5 rounded-md px-2 font-medium transition-colors disabled:cursor-default"
data-component="QueuedMessageStatus"
>
{pendingAction === "mode" ? (
<Loader2 className="size-3 shrink-0 animate-spin" />
) : (
<Clock3 className="text-pending size-3 shrink-0" />
)}
<span className="text-foreground shrink-0">Queued</span>
<span className="truncate">{queueStatusLabel}</span>
<ChevronDown className="size-3 shrink-0" />
<span className="text-foreground shrink-0">
{isDispatching ? "Sending" : "Queued"}
</span>
{!isDispatching && <span className="truncate">{queueStatusLabel}</span>}
{!isDispatching && <ChevronDown className="size-3 shrink-0" />}
</button>

{isMenuOpen && (
{!isDispatching && isMenuOpen && (
<div
role="menu"
className="bg-separator border-border-light absolute right-0 bottom-full z-[1020] mb-1 min-w-[12rem] rounded-md border p-1.5 shadow-md"
Expand Down
67 changes: 67 additions & 0 deletions src/node/services/agentSession.queueDispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,73 @@ describe("AgentSession queued message tool-call dispatch", () => {
}
});

test("keeps a dequeued user message visible until its durable row is emitted", async () => {
const workspaceId = "queue-dispatch-visible-handoff";
const { session, cleanup, historyService, events } = await createAgentSessionHarness({
workspaceId,
captureEvents: true,
});
const originalAppend = historyService.appendToHistory.bind(historyService);
const appendStarted = Promise.withResolvers<void>();
const appendRelease = Promise.withResolvers<void>();
const appendSpy = spyOn(historyService, "appendToHistory").mockImplementation(
async (...args) => {
appendStarted.resolve();
await appendRelease.promise;
return originalAppend(...args);
}
);
const followUp = "Follow up after compaction";
const nextFollowUp = "A later queued message";
const isFollowUpUserMessage = (event: (typeof events)[number]) =>
event.type === "message" &&
event.role === "user" &&
event.parts.some((part) => part.type === "text" && part.text === followUp);
const latestQueueEvent = () =>
events.filter((event) => event.type === "queued-message-changed").at(-1);

try {
session.queueMessage(followUp, { model: TEST_MODEL, agentId: "exec" });
const queueEventCountBeforeDispatch = events.filter(
(event) => event.type === "queued-message-changed"
).length;
session.sendQueuedMessages();
await appendStarted.promise;

expect(events.filter((event) => event.type === "queued-message-changed").length).toBe(
queueEventCountBeforeDispatch + 1
);
expect(latestQueueEvent()?.queuedMessages).toEqual([followUp]);
expect(events.some(isFollowUpUserMessage)).toBe(false);

// Any queue mutation during persistence must retain the in-flight entry in the
// authoritative projection instead of falling back to a stale renderer snapshot.
session.queueMessage(nextFollowUp, { model: TEST_MODEL, agentId: "exec" });
expect(latestQueueEvent()?.queuedMessages).toEqual([followUp, nextFollowUp]);

appendRelease.resolve();
expect(await waitForCondition(() => events.some(isFollowUpUserMessage))).toBe(true);
expect(
await waitForCondition(() => latestQueueEvent()?.queuedMessages.join() === nextFollowUp)
).toBe(true);

const userMessageIndex = events.findIndex(isFollowUpUserMessage);
const handoffIndex = events.findIndex(
(event, index) =>
index > userMessageIndex &&
event.type === "queued-message-changed" &&
event.queuedMessages.join() === nextFollowUp
);
expect(userMessageIndex).toBeGreaterThanOrEqual(0);
expect(handoffIndex).toBeGreaterThan(userMessageIndex);
} finally {
appendRelease.resolve();
appendSpy.mockRestore();
session.dispose();
await cleanup();
}
});

test("cancel signal retracts a synthetic entry after dequeue while history append is preparing", async () => {
const workspaceId = "queue-dispatch-cancel-preparing";
const { session, cleanup, historyService, events } = await createAgentSessionHarness({
Expand Down
Loading
Loading