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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user