diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 80b86fc7..4275b5af 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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.", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 140c6761..b68f9b82 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -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-агент не смог ответить. Попробуйте ещё раз.", diff --git a/apps/client/src/features/ai-chat/components/ai-chat.module.css b/apps/client/src/features/ai-chat/components/ai-chat.module.css index 8d75407f..7680e9ec 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat.module.css +++ b/apps/client/src/features/ai-chat/components/ai-chat.module.css @@ -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; +} diff --git a/apps/client/src/features/ai-chat/components/chat-input.tsx b/apps/client/src/features/ai-chat/components/chat-input.tsx index 9713fea9..58661cb6 100644 --- a/apps/client/src/features/ai-chat/components/chat-input.tsx +++ b/apps/client/src/features/ai-chat/components/chat-input.tsx @@ -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): void => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - send(); + submit(); } }; @@ -70,23 +77,37 @@ export default function ChatInput({ /> )} {isStreaming ? ( - - - - - + + {value.trim().length > 0 && ( + + + + + + )} + + + + + + ) : ( diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 0b14b646..7243f2ad 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -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([]); + // 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([]); + 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({ @@ -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({ )} + {queued.length > 0 && ( + + {queued.map((m) => ( + + + + {m.text} + + removeQueued(m.id)} + aria-label={t("Remove queued message")} + > + + + + ))} + + )} sendMessage({ text })} + onQueue={enqueue} onStop={stop} isStreaming={isStreaming} /> diff --git a/apps/client/src/features/ai-chat/utils/queue-helpers.test.ts b/apps/client/src/features/ai-chat/utils/queue-helpers.test.ts new file mode 100644 index 00000000..6022c46d --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/queue-helpers.test.ts @@ -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"]); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/queue-helpers.ts b/apps/client/src/features/ai-chat/utils/queue-helpers.ts new file mode 100644 index 00000000..15efe2c9 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/queue-helpers.ts @@ -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); +}