Files
multica/packages/views/chat/components/chat-queue.test.tsx
Naiyuan Qing a8d5daac50 fix(chat): decouple the follow-up queue card from the composer chrome (#6444)
* fix(chat): decouple the follow-up queue card from the composer chrome

The queue used to restyle the composer on appearance (hasQueue prop
flipping rounded-lg to rounded-4xl plus a stronger shadow), which made
the input box visibly jump the moment a message was queued. The stack
also relied on a three-way implicit contract (-mb-8 / pb-10 / z-10)
spread across both components.

Now the composer chrome is constant — no hasQueue prop, no conditional
classes, only a static z-10 so it always paints on top — and the tucked
look is owned entirely by ChatQueue: it slides its own bottom edge under
the composer (z-0, -mb-3, pb-4) and insets itself (mx-3) so the stack
reads as two distinct layers. The queue chrome itself is lighter and
smaller: caption-size muted rows, xs action buttons, border-only card,
and an animate-in entrance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(chat): align queue scroller cap assertion with max-h-40

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:13:42 +08:00

128 lines
5.1 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import { describe, expect, it, vi } from "vitest";
import enChat from "../../locales/en/chat.json";
import { ChatQueue } from "./chat-queue";
const TEST_RESOURCES = { en: { chat: enChat } };
function renderQueue(headStatus = "running") {
const callbacks = {
onSendNow: vi.fn<(taskId: string) => Promise<void>>().mockResolvedValue(),
onEdit: vi.fn<(taskId: string) => Promise<void>>().mockResolvedValue(),
onRemove: vi.fn<(taskId: string) => Promise<void>>().mockResolvedValue(),
onClear: vi.fn<() => Promise<void>>().mockResolvedValue(),
};
const view = render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatQueue
headStatus={headStatus}
tasks={[
{
task_id: "task-2",
status: "queued",
content: "First follow-up",
created_at: "2026-07-01T00:01:00Z",
},
{
task_id: "task-3",
status: "queued",
content: "",
created_at: "2026-07-01T00:02:00Z",
},
]}
{...callbacks}
/>
</I18nProvider>,
);
return { ...callbacks, container: view.container };
}
describe("ChatQueue", () => {
it("renders a standalone queue card without a separate header", () => {
const { container } = renderQueue();
expect(screen.getByRole("region", { name: "2 queued messages" })).toBeInTheDocument();
expect(screen.queryByText("2 queued messages")).not.toBeInTheDocument();
expect(screen.getByText("First follow-up")).toBeInTheDocument();
expect(screen.getByText("Queued message")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Steer" })).toHaveLength(2);
expect(screen.getAllByLabelText("Remove queued message")).toHaveLength(2);
expect(screen.getAllByLabelText("More queue actions")).toHaveLength(2);
expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument();
const shell = container.querySelector('[data-slot="chat-queue-shell"]');
const queue = container.querySelector('[data-slot="chat-queue"]');
// Tucked stack, owned entirely by the queue: it slides under the composer
// (negative margin + z-0) while the composer's chrome stays untouched.
expect(shell).toHaveClass("z-0", "-mb-3");
expect(queue).toHaveClass(
"rounded-lg",
"border-surface-border",
"bg-surface",
"pb-4",
);
expect(container.querySelectorAll('[data-slot="chat-queue-row"]')).toHaveLength(2);
expect(container.querySelectorAll('[data-slot="chat-queue-item-icon"]')).toHaveLength(2);
});
it("runs steer, remove, and overflow actions against the selected queue state", async () => {
const actions = renderQueue();
fireEvent.click(screen.getAllByRole("button", { name: "Steer" })[1]!);
await waitFor(() => expect(actions.onSendNow).toHaveBeenCalledWith("task-3"));
fireEvent.click(screen.getAllByLabelText("More queue actions")[0]!);
fireEvent.click(await screen.findByRole("menuitem", { name: "Edit queued message" }));
await waitFor(() => expect(actions.onEdit).toHaveBeenCalledWith("task-2"));
fireEvent.click(screen.getAllByLabelText("Remove queued message")[1]!);
await waitFor(() => expect(actions.onRemove).toHaveBeenCalledWith("task-3"));
fireEvent.click(screen.getAllByLabelText("More queue actions")[1]!);
fireEvent.click(await screen.findByRole("menuitem", { name: "Clear all" }));
await waitFor(() => expect(actions.onClear).toHaveBeenCalledTimes(1));
});
it("disables send-now until the current positional head is claimable", () => {
const actions = renderQueue("queued");
const buttons = screen.getAllByRole("button", {
name: "Steer is available after the current reply starts",
});
expect(buttons).toHaveLength(2);
for (const button of buttons) expect(button).toBeDisabled();
expect(actions.onSendNow).not.toHaveBeenCalled();
});
it("keeps long queues bounded and blocks duplicate actions while one is pending", async () => {
let finishClear: (() => void) | undefined;
const actions = renderQueue();
actions.onClear.mockReturnValue(new Promise<void>((resolve) => {
finishClear = resolve;
}));
const scroller = actions.container.querySelector('[data-slot="chat-queue-list"]');
expect(scroller).toHaveClass("max-h-40");
const clearTrigger = screen.getAllByLabelText("More queue actions")[0]!;
fireEvent.click(clearTrigger);
fireEvent.click(await screen.findByRole("menuitem", { name: "Clear all" }));
await waitFor(() => {
expect(clearTrigger.querySelector(".animate-spin")).toBeInTheDocument();
for (const button of screen.getAllByRole("button")) {
expect(button).toBeDisabled();
}
});
finishClear?.();
await waitFor(() => {
expect(clearTrigger.querySelector(".animate-spin")).not.toBeInTheDocument();
for (const button of screen.getAllByRole("button")) {
expect(button).toBeEnabled();
}
});
expect(actions.onClear).toHaveBeenCalledTimes(1);
});
});