feat(ai-chat): queue messages typed while the agent is streaming

Previously a message composed while the AI agent was streaming a reply was
silently dropped (the composer early-returned on isStreaming). Now such
messages are queued FIFO and sent automatically once the current turn
finishes cleanly.

- chat-input: submit() enqueues while streaming (via new onQueue prop) and
  sends otherwise; during streaming show a queue Send button (when text is
  present) alongside the Stop button; the textarea stays usable.
- chat-thread: per-conversation queue in local state (mirrored in a ref);
  flush the next message in onFinish ONLY on a clean finish - ai@6 useChat
  fires onFinish from a finally on Stop/disconnect/error too, where the queue
  must be preserved. Pending messages render as removable chips above the
  composer. Queue is cleared on chat switch (parent remount) and survives
  in-place new-chat id adoption.
- queue-helpers: pure FIFO helpers (enqueue/dequeue/removeQueuedById) + tests.
- i18n: add en-US/ru-RU keys (Queue message, Remove queued message,
  Send when the agent finishes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-22 18:43:22 +03:00
parent e598394f46
commit e423c35676
7 changed files with 312 additions and 29 deletions

View File

@@ -1150,6 +1150,9 @@
"AI agent is typing…": "AI agent is typing…", "AI agent is typing…": "AI agent is typing…",
"{{name}} is typing…": "{{name}} is typing…", "{{name}} is typing…": "{{name}} is typing…",
"Send": "Send", "Send": "Send",
"Send when the agent finishes": "Send when the agent finishes",
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Stop": "Stop", "Stop": "Stop",
"Chat menu": "Chat menu", "Chat menu": "Chat menu",
"No chats yet.": "No chats yet.", "No chats yet.": "No chats yet.",

View File

@@ -693,6 +693,9 @@
"Minimize": "Свернуть", "Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.", "No chats yet.": "Чатов пока нет.",
"Send": "Отправить", "Send": "Отправить",
"Send when the agent finishes": "Отправить, когда агент закончит",
"Queue message": "Поставить в очередь",
"Remove queued message": "Убрать из очереди",
"Something went wrong": "Что-то пошло не так", "Something went wrong": "Что-то пошло не так",
"Stop": "Стоп", "Stop": "Стоп",
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.", "The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",

View File

@@ -128,3 +128,29 @@
.conversationItemActive { .conversationItemActive {
background: var(--mantine-color-gray-light); background: var(--mantine-color-gray-light);
} }
/* Pending messages queued by the user while a turn is still streaming. They
are sent automatically, FIFO, once the current turn finishes. */
.queuedList {
padding-bottom: var(--mantine-spacing-xs);
}
.queuedItem {
background: var(--mantine-color-gray-light);
border-radius: var(--mantine-radius-sm);
padding: 4px 8px;
}
.queuedIcon {
flex: none;
color: var(--mantine-color-dimmed);
}
.queuedText {
flex: 1;
min-width: 0;
color: var(--mantine-color-dimmed);
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}

View File

@@ -9,18 +9,24 @@ import { MicButton } from "@/features/dictation/components/mic-button";
interface ChatInputProps { interface ChatInputProps {
onSend: (text: string) => void; onSend: (text: string) => void;
/** Called instead of `onSend` while a turn is streaming: the text is queued
* and sent automatically once the current turn finishes. */
onQueue: (text: string) => void;
onStop: () => void; onStop: () => void;
isStreaming: boolean; isStreaming: boolean;
disabled?: boolean; disabled?: boolean;
} }
/** /**
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent * Message composer. Enter submits, Shift+Enter inserts a newline. While the
* is streaming, the send button becomes a Stop button (calls `stop()`); the * agent is streaming, submitting QUEUES the message (via `onQueue`) instead of
* textarea stays usable so the user can draft the next turn. * dropping it — it is sent automatically once the current turn finishes; the
* Stop button (calls `stop()`) is also shown. The textarea stays usable so the
* user can draft / queue the next turn while the agent is busy.
*/ */
export default function ChatInput({ export default function ChatInput({
onSend, onSend,
onQueue,
onStop, onStop,
isStreaming, isStreaming,
disabled, disabled,
@@ -30,17 +36,18 @@ export default function ChatInput({
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true; const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
const send = (): void => { const submit = (): void => {
const text = value.trim(); const text = value.trim();
if (!text || isStreaming || disabled) return; if (!text || disabled) return;
onSend(text); if (isStreaming) onQueue(text);
else onSend(text);
setValue(""); setValue("");
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
send(); submit();
} }
}; };
@@ -70,6 +77,19 @@ export default function ChatInput({
/> />
)} )}
{isStreaming ? ( {isStreaming ? (
<Group gap="xs" wrap="nowrap">
{value.trim().length > 0 && (
<Tooltip label={t("Send when the agent finishes")} withArrow>
<ActionIcon
size="lg"
variant="filled"
onClick={submit}
aria-label={t("Queue message")}
>
<IconSend size={18} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Stop")} withArrow> <Tooltip label={t("Stop")} withArrow>
<ActionIcon <ActionIcon
size="lg" size="lg"
@@ -81,12 +101,13 @@ export default function ChatInput({
<IconPlayerStopFilled size={18} /> <IconPlayerStopFilled size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group>
) : ( ) : (
<Tooltip label={t("Send")} withArrow> <Tooltip label={t("Send")} withArrow>
<ActionIcon <ActionIcon
size="lg" size="lg"
variant="filled" variant="filled"
onClick={send} onClick={submit}
disabled={disabled || value.trim().length === 0} disabled={disabled || value.trim().length === 0}
aria-label={t("Send")} aria-label={t("Send")}
> >

View File

@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { generateId } from "ai"; import { generateId } from "ai";
import { Alert, Box, Stack } from "@mantine/core"; import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react"; import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@@ -13,6 +13,12 @@ import {
IAiRole, IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts"; } from "@/features/ai-chat/types/ai-chat.types.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts"; import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import {
dequeue,
enqueueMessage,
removeQueuedById,
type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
/** The page the user is currently viewing, sent as chat context. */ /** The page the user is currently viewing, sent as chat context. */
@@ -137,6 +143,48 @@ export default function ChatThread({
// prepareSendMessagesRequest), so this purely-client store key can stay fixed. // prepareSendMessagesRequest), so this purely-client store key can stay fixed.
const chatStoreId = stableIdRef.current; const chatStoreId = stableIdRef.current;
// Pending messages the user composed WHILE a turn was streaming. They are sent
// automatically, FIFO, on successful turn completion (`onFinish`). The queue is
// LOCAL state so it is scoped to this conversation: it is cleared when the user
// deliberately switches chat / starts a new chat (the parent remounts this via
// `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a
// message queued during a brand-new chat's first turn is not lost. On Stop or
// error the queue is intentionally preserved (onFinish does not fire then) so
// the user decides what to do with the pending messages.
const [queued, setQueued] = useState<QueuedMessage[]>([]);
// Mirror the queue in a ref so the `onFinish` flush always reads the latest
// queue without a stale closure; `setQueue` updates BOTH the ref and the state.
const queuedRef = useRef<QueuedMessage[]>([]);
const setQueue = useCallback((next: QueuedMessage[]) => {
queuedRef.current = next;
setQueued(next);
}, []);
// Capture the latest `sendMessage` (returned by useChat below) so the flush
// helper can call the current instance from the stable `onFinish` callback.
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
const flushNext = useCallback(() => {
const { head, rest } = dequeue(queuedRef.current);
if (!head) return;
setQueue(rest);
sendMessageRef.current?.({ text: head.text });
}, [setQueue]);
const enqueue = useCallback(
(text: string) => {
setQueue(enqueueMessage(queuedRef.current, { id: generateId(), text }));
},
[setQueue],
);
const removeQueued = useCallback(
(id: string) => {
setQueue(removeQueuedById(queuedRef.current, id));
},
[setQueue],
);
const transport = useMemo( const transport = useMemo(
() => () =>
new DefaultChatTransport<UIMessage>({ new DefaultChatTransport<UIMessage>({
@@ -169,13 +217,24 @@ export default function ChatThread({
id: chatStoreId, id: chatStoreId,
messages: initialMessages, messages: initialMessages,
transport, transport,
onFinish: () => onTurnFinished(), // `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand // — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
// new chat that fails on its first turn would never invalidate the chat list // stream error (`isError`). Keep calling `onTurnFinished()` on all of them
// nor adopt the server-created chat id (the server still creates the row and // (chat-list refresh + new-chat id adoption must happen even on a failed
// saves the error message). Run the same post-turn path on error so the // first turn), but flush the pending queue ONLY on a clean finish: auto-
// failed chat appears in history immediately instead of after a manual // sending after the user hit Stop — or blindly retrying after a failure —
// refresh. The error itself is still surfaced via `error` below. // would be wrong, so on Stop/disconnect/error the queue is left intact for
// the user to decide.
onFinish: ({ isAbort, isDisconnect, isError }) => {
onTurnFinished();
if (isAbort || isDisconnect || isError) return;
flushNext();
},
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
// Log the raw failure here for devtools; the UI shows a friendly classified
// banner via `error` below. We still call `onTurnFinished()` (idempotent with
// the onFinish call) so a brand-new chat that fails its first turn is adopted
// and the chat list refreshes immediately rather than after a manual refresh.
onError: (streamError) => { onError: (streamError) => {
// Surface the raw failure in the browser console (devtools) for debugging; // Surface the raw failure in the browser console (devtools) for debugging;
// the UI separately shows a friendly classified banner (see errorView). // the UI separately shows a friendly classified banner (see errorView).
@@ -184,6 +243,9 @@ export default function ChatThread({
}, },
}); });
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
const isStreaming = status === "submitted" || status === "streaming"; const isStreaming = status === "submitted" || status === "streaming";
// Classify the turn error into a heading + detail so the banner names the cause // Classify the turn error into a heading + detail so the banner names the cause
@@ -227,8 +289,35 @@ export default function ChatThread({
)} )}
<Stack gap={0} className={classes.inputWrapper}> <Stack gap={0} className={classes.inputWrapper}>
{queued.length > 0 && (
<Stack gap={4} className={classes.queuedList}>
{queued.map((m) => (
<Group
key={m.id}
gap={6}
wrap="nowrap"
className={classes.queuedItem}
>
<IconClockHour4 size={14} className={classes.queuedIcon} />
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => removeQueued(m.id)}
aria-label={t("Remove queued message")}
>
<IconX size={12} />
</ActionIcon>
</Group>
))}
</Stack>
)}
<ChatInput <ChatInput
onSend={(text) => sendMessage({ text })} onSend={(text) => sendMessage({ text })}
onQueue={enqueue}
onStop={stop} onStop={stop}
isStreaming={isStreaming} isStreaming={isStreaming}
/> />

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import {
enqueueMessage,
dequeue,
removeQueuedById,
type QueuedMessage,
} from "./queue-helpers";
describe("enqueueMessage", () => {
it("appends a message to the end of the queue", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
const next = enqueueMessage(queue, { id: "b", text: "second" });
expect(next).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
enqueueMessage(queue, { id: "b", text: "second" });
expect(queue).toEqual([{ id: "a", text: "first" }]);
});
});
describe("dequeue", () => {
it("returns {head:null, rest:[]} for an empty queue", () => {
expect(dequeue([])).toEqual({ head: null, rest: [] });
});
it("returns the first item as head and the remainder as rest", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
const { head, rest } = dequeue(queue);
expect(head).toEqual({ id: "a", text: "first" });
expect(rest).toEqual([
{ id: "b", text: "second" },
{ id: "c", text: "third" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
dequeue(queue);
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("removeQueuedById", () => {
it("removes the matching id and leaves the others", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
const next = removeQueuedById(queue, "b");
expect(next).toEqual([
{ id: "a", text: "first" },
{ id: "c", text: "third" },
]);
});
it("returns an equivalent list when the id is not present", () => {
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
expect(removeQueuedById(queue, "missing")).toEqual([
{ id: "a", text: "first" },
]);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
removeQueuedById(queue, "a");
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("FIFO order", () => {
it("preserves order across enqueue -> dequeue", () => {
let queue: QueuedMessage[] = [];
queue = enqueueMessage(queue, { id: "1", text: "one" });
queue = enqueueMessage(queue, { id: "2", text: "two" });
queue = enqueueMessage(queue, { id: "3", text: "three" });
const order: string[] = [];
while (queue.length > 0) {
const { head, rest } = dequeue(queue);
if (head) order.push(head.text);
queue = rest;
}
expect(order).toEqual(["one", "two", "three"]);
});
});

View File

@@ -0,0 +1,34 @@
// Pure FIFO helpers for the AI-chat "send while the agent is busy" queue.
// Kept side-effect free so they can be unit-tested without React.
export interface QueuedMessage {
id: string;
text: string;
}
/** Append a message to the end of the queue (returns a new array). */
export function enqueueMessage(
queue: QueuedMessage[],
message: QueuedMessage,
): QueuedMessage[] {
return [...queue, message];
}
/** Split the queue into its first item (`head`) and the remainder (`rest`).
* `head` is null when the queue is empty. Does not mutate the input. */
export function dequeue(queue: QueuedMessage[]): {
head: QueuedMessage | null;
rest: QueuedMessage[];
} {
if (queue.length === 0) return { head: null, rest: [] };
const [head, ...rest] = queue;
return { head, rest };
}
/** Remove the queued message with the given id (returns a new array). */
export function removeQueuedById(
queue: QueuedMessage[],
id: string,
): QueuedMessage[] {
return queue.filter((m) => m.id !== id);
}