feat(chat): add composer action menu

This commit is contained in:
2026-07-21 10:58:40 +08:00
parent 17e5656f53
commit 7789673fff
11 changed files with 632 additions and 62 deletions
@@ -0,0 +1,174 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
}));
vi.mock("@/stores/chat/chat-context", () => ({
useChatDispatch: () => mocks.dispatch,
}));
vi.mock("@/providers/character-provider", () => ({
useActiveCharacter: () => ({ id: "maya-tan" }),
useActiveCharacterRoutes: () => ({
tip: "/characters/maya/tip",
}),
}));
vi.mock("../../hooks/use-chat-keyboard-avoidance", () => ({
useChatKeyboardAvoidance: () => undefined,
}));
vi.mock("../../hooks/use-chat-keyboard-diagnostics", () => ({
useChatKeyboardDiagnostics: () => undefined,
}));
import { ChatInputBar } from "../chat-input-bar";
describe("ChatInputBar", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.dispatch.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("opens the ordered action menu, blurs input, and links to Tip", () => {
renderBar();
const textarea = getTextarea();
act(() => textarea.focus());
expect(document.activeElement).toBe(textarea);
act(() => getButton("Open chat actions").click());
expect(document.activeElement).not.toBe(textarea);
const menu = container.querySelector('[aria-label="Chat actions"]');
expect(menu).not.toBeNull();
expect(
Array.from(menu?.children ?? []).map((item) => item.textContent?.trim()),
).toEqual(["Image", "Voice", "Tip"]);
expect(getButton("Close chat actions").getAttribute("aria-expanded")).toBe(
"true",
);
expect(
menu
?.querySelector<HTMLAnchorElement>('[data-analytics-key="chat.open_tip"]')
?.getAttribute("href"),
).toBe("/characters/maya/tip");
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
});
it("injects image and voice promotions without persisting UI state", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => getButton("Image").click());
const imageEvent = mocks.dispatch.mock.calls[0]?.[0];
expect(imageEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "image",
lockType: "image_paywall",
},
});
expect(imageEvent.promotion.clientLockId).toMatch(/^promotion_/);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
act(() => getButton("Voice").click());
const voiceEvent = mocks.dispatch.mock.calls[1]?.[0];
expect(voiceEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "voice",
lockType: "voice_message",
},
});
expect(voiceEvent.promotion.clientLockId).not.toBe(
imageEvent.promotion.clientLockId,
);
});
it("switches from actions to Send for non-whitespace input", () => {
renderBar();
act(() => getButton("Open chat actions").click());
setTextareaValue(getTextarea(), "Hello Maya");
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Send message")).not.toBeNull();
act(() => getButton("Send message").click());
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "ChatSendMessage",
content: "Hello Maya",
});
expect(getTextarea().value).toBe("");
expect(document.activeElement).toBe(getTextarea());
setTextareaValue(getTextarea(), " ");
expect(getButton("Open chat actions")).not.toBeNull();
});
it("closes the menu on outside interaction and when disabled", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
renderBar(true);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Open chat actions").disabled).toBe(true);
});
function renderBar(disabled = false): void {
act(() => root.render(<ChatInputBar disabled={disabled} />));
}
function getTextarea(): HTMLTextAreaElement {
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Missing chat textarea");
return textarea;
}
function getButton(label: string): HTMLButtonElement {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find(
(item) =>
item.getAttribute("aria-label") === label ||
item.textContent?.trim() === label,
);
if (!button) throw new Error(`Missing button: ${label}`);
return button;
}
});
function setTextareaValue(
textarea: HTMLTextAreaElement,
value: string,
): void {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
act(() => {
setter?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}