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:
@@ -1150,6 +1150,9 @@
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
"{{name}} is typing…": "{{name}} is typing…",
|
||||
"Send": "Send",
|
||||
"Send when the agent finishes": "Send when the agent finishes",
|
||||
"Queue message": "Queue message",
|
||||
"Remove queued message": "Remove queued message",
|
||||
"Stop": "Stop",
|
||||
"Chat menu": "Chat menu",
|
||||
"No chats yet.": "No chats yet.",
|
||||
|
||||
@@ -693,6 +693,9 @@
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
|
||||
@@ -128,3 +128,29 @@
|
||||
.conversationItemActive {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,24 @@ import { MicButton } from "@/features/dictation/components/mic-button";
|
||||
|
||||
interface ChatInputProps {
|
||||
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;
|
||||
isStreaming: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent
|
||||
* is streaming, the send button becomes a Stop button (calls `stop()`); the
|
||||
* textarea stays usable so the user can draft the next turn.
|
||||
* Message composer. Enter submits, Shift+Enter inserts a newline. While the
|
||||
* agent is streaming, submitting QUEUES the message (via `onQueue`) instead of
|
||||
* 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({
|
||||
onSend,
|
||||
onQueue,
|
||||
onStop,
|
||||
isStreaming,
|
||||
disabled,
|
||||
@@ -30,17 +36,18 @@ export default function ChatInput({
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
|
||||
const send = (): void => {
|
||||
const submit = (): void => {
|
||||
const text = value.trim();
|
||||
if (!text || isStreaming || disabled) return;
|
||||
onSend(text);
|
||||
if (!text || disabled) return;
|
||||
if (isStreaming) onQueue(text);
|
||||
else onSend(text);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,23 +77,37 @@ export default function ChatInput({
|
||||
/>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<Tooltip label={t("Stop")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
aria-label={t("Stop")}
|
||||
>
|
||||
<IconPlayerStopFilled size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<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>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
aria-label={t("Stop")}
|
||||
>
|
||||
<IconPlayerStopFilled size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label={t("Send")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={send}
|
||||
onClick={submit}
|
||||
disabled={disabled || value.trim().length === 0}
|
||||
aria-label={t("Send")}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { Alert, Box, Stack } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
IAiRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.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";
|
||||
|
||||
/** 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.
|
||||
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(
|
||||
() =>
|
||||
new DefaultChatTransport<UIMessage>({
|
||||
@@ -169,13 +217,24 @@ export default function ChatThread({
|
||||
id: chatStoreId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
onFinish: () => onTurnFinished(),
|
||||
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand
|
||||
// new chat that fails on its first turn would never invalidate the chat list
|
||||
// nor adopt the server-created chat id (the server still creates the row and
|
||||
// saves the error message). Run the same post-turn path on error so the
|
||||
// failed chat appears in history immediately instead of after a manual
|
||||
// refresh. The error itself is still surfaced via `error` below.
|
||||
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
|
||||
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
|
||||
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
|
||||
// (chat-list refresh + new-chat id adoption must happen even on a failed
|
||||
// first turn), but flush the pending queue ONLY on a clean finish: auto-
|
||||
// sending after the user hit Stop — or blindly retrying after a failure —
|
||||
// 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) => {
|
||||
// Surface the raw failure in the browser console (devtools) for debugging;
|
||||
// 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";
|
||||
|
||||
// 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}>
|
||||
{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
|
||||
onSend={(text) => sendMessage({ text })}
|
||||
onQueue={enqueue}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
|
||||
107
apps/client/src/features/ai-chat/utils/queue-helpers.test.ts
Normal file
107
apps/client/src/features/ai-chat/utils/queue-helpers.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
34
apps/client/src/features/ai-chat/utils/queue-helpers.ts
Normal file
34
apps/client/src/features/ai-chat/utils/queue-helpers.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user