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

@@ -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"]);
});
});