mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-14 06:43:53 +00:00
1271 lines
40 KiB
TypeScript
1271 lines
40 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
|
import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
|
|
|
vi.mock("@/lib/imageEncode", () => ({
|
|
encodeImage: vi.fn(async (file: File) => ({
|
|
ok: true,
|
|
dataUrl: `data:${file.type || "image/png"};base64,aW1hZ2U=`,
|
|
bytes: Math.max(1, file.size),
|
|
normalized: false,
|
|
})),
|
|
}));
|
|
|
|
const COMMANDS: SlashCommand[] = [
|
|
{
|
|
command: "/stop",
|
|
title: "Stop current task",
|
|
description: "Cancel the active agent turn.",
|
|
icon: "square",
|
|
},
|
|
{
|
|
command: "/history",
|
|
title: "Show conversation history",
|
|
description: "Print the last N persisted messages.",
|
|
icon: "history",
|
|
argHint: "[n]",
|
|
},
|
|
];
|
|
|
|
const CLI_APPS: CliAppInfo[] = [
|
|
{
|
|
name: "gimp",
|
|
display_name: "GIMP",
|
|
category: "image",
|
|
description: "Image editing",
|
|
requires: "",
|
|
source: "harness",
|
|
entry_point: "cli-anything-gimp",
|
|
install_supported: true,
|
|
installed: true,
|
|
available: true,
|
|
status: "installed",
|
|
logo_url: "https://example.invalid/gimp.svg",
|
|
brand_color: "#5C5543",
|
|
skill_installed: true,
|
|
},
|
|
{
|
|
name: "blender",
|
|
display_name: "Blender",
|
|
category: "3d",
|
|
description: "3D creation",
|
|
requires: "",
|
|
source: "harness",
|
|
entry_point: "cli-anything-blender",
|
|
install_supported: true,
|
|
installed: true,
|
|
available: true,
|
|
status: "installed",
|
|
logo_url: null,
|
|
brand_color: "#E87D0D",
|
|
skill_installed: true,
|
|
},
|
|
{
|
|
name: "krita",
|
|
display_name: "Krita",
|
|
category: "image",
|
|
description: "Painting",
|
|
requires: "",
|
|
source: "harness",
|
|
entry_point: "cli-anything-krita",
|
|
install_supported: true,
|
|
installed: false,
|
|
available: false,
|
|
status: "not_installed",
|
|
logo_url: null,
|
|
brand_color: "#3BABFF",
|
|
skill_installed: false,
|
|
},
|
|
];
|
|
|
|
const MCP_PRESETS: McpPresetInfo[] = [
|
|
{
|
|
name: "browserbase",
|
|
display_name: "Browserbase",
|
|
category: "browser",
|
|
description: "Cloud browser automation",
|
|
docs_url: "https://docs.browserbase.com",
|
|
transport: "streamableHttp",
|
|
requires: "Browserbase API key",
|
|
note: "",
|
|
install_supported: true,
|
|
installed: true,
|
|
configured: true,
|
|
available: true,
|
|
status: "configured",
|
|
logo_url: "https://example.invalid/browserbase.svg",
|
|
brand_color: "#111827",
|
|
required_fields: [],
|
|
connection_summary: "https://mcp.browserbase.com/mcp",
|
|
},
|
|
{
|
|
name: "figma",
|
|
display_name: "Figma",
|
|
category: "design",
|
|
description: "Design context",
|
|
docs_url: "https://figma.com",
|
|
transport: "streamableHttp",
|
|
requires: "Figma local app",
|
|
note: "",
|
|
install_supported: true,
|
|
installed: true,
|
|
configured: false,
|
|
available: false,
|
|
status: "missing_credentials",
|
|
logo_url: null,
|
|
brand_color: "#F24E1E",
|
|
required_fields: [],
|
|
connection_summary: "",
|
|
},
|
|
];
|
|
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
|
|
|
function mockBlobUrls() {
|
|
Object.defineProperty(URL, "createObjectURL", {
|
|
configurable: true,
|
|
value: vi.fn(() => "blob:composer-test"),
|
|
});
|
|
Object.defineProperty(URL, "revokeObjectURL", {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
Reflect.deleteProperty(window, "nanobotHost");
|
|
window.localStorage.clear();
|
|
Object.defineProperty(window, "innerHeight", {
|
|
value: ORIGINAL_INNER_HEIGHT,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
function rect(init: Partial<DOMRect>): DOMRect {
|
|
const top = init.top ?? 0;
|
|
const left = init.left ?? 0;
|
|
const width = init.width ?? 0;
|
|
const height = init.height ?? 0;
|
|
return {
|
|
x: init.x ?? left,
|
|
y: init.y ?? top,
|
|
top,
|
|
left,
|
|
width,
|
|
height,
|
|
right: init.right ?? left + width,
|
|
bottom: init.bottom ?? top + height,
|
|
toJSON: () => ({}),
|
|
};
|
|
}
|
|
|
|
describe("ThreadComposer", () => {
|
|
it("renders a readonly hero model composer when provided", () => {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
modelLabel="claude-opus-4-5"
|
|
placeholder="Ask anything..."
|
|
variant="hero"
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument();
|
|
const input = screen.getByPlaceholderText("Ask anything...");
|
|
expect(input).toBeInTheDocument();
|
|
expect(input.className).toContain("min-h-[78px]");
|
|
expect(input.className).toContain("pt-[27px]");
|
|
fireEvent.change(input, { target: { value: "1" } });
|
|
expect(input.className).toContain("pt-[27px]");
|
|
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
|
|
});
|
|
|
|
it("keeps the thread composer compact while matching the hero style", () => {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
modelLabel="gpt-4o"
|
|
modelProvider="openai"
|
|
modelProviderLabel="OpenAI"
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
|
|
expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument();
|
|
const input = screen.getByPlaceholderText("Type your message...");
|
|
expect(input.className).toContain("min-h-[50px]");
|
|
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
|
|
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
|
|
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
|
|
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
|
|
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
|
|
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders and changes workspace access mode", async () => {
|
|
const onWorkspaceScopeChange = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
workspaceScope={{
|
|
project_path: "/tmp/project",
|
|
project_name: "project",
|
|
access_mode: "restricted",
|
|
restrict_to_workspace: true,
|
|
}}
|
|
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: "Workspace access mode" }));
|
|
fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ }));
|
|
|
|
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
project_path: "/tmp/project",
|
|
access_mode: "full",
|
|
restrict_to_workspace: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps project selection as a compact composer dropdown", async () => {
|
|
const onWorkspaceScopeChange = vi.fn();
|
|
const defaultScope = {
|
|
project_path: "/Users/test/.nanobot/workspace",
|
|
project_name: "workspace",
|
|
access_mode: "restricted" as const,
|
|
restrict_to_workspace: true,
|
|
};
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Ask anything..."
|
|
variant="hero"
|
|
workspaceScope={{
|
|
...defaultScope,
|
|
access_mode: "full",
|
|
restrict_to_workspace: false,
|
|
}}
|
|
workspaceDefaultScope={defaultScope}
|
|
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
|
|
|
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
|
|
const input = screen.getByLabelText("Paste path");
|
|
fireEvent.change(input, { target: { value: "relative/project" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
"Enter an absolute folder path on this machine.",
|
|
);
|
|
expect(onWorkspaceScopeChange).not.toHaveBeenCalled();
|
|
|
|
fireEvent.change(input, { target: { value: "/Users/test/project-alpha" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
|
|
|
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
project_path: "/Users/test/project-alpha",
|
|
project_name: "project-alpha",
|
|
access_mode: "full",
|
|
restrict_to_workspace: false,
|
|
}));
|
|
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
|
const reopenedInput = await screen.findByLabelText("Paste path");
|
|
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
|
|
|
expect(onWorkspaceScopeChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
project_path: "~/Pictures/Photos",
|
|
project_name: "Photos",
|
|
access_mode: "full",
|
|
restrict_to_workspace: false,
|
|
}));
|
|
});
|
|
|
|
it("uses the native folder picker for project selection on native host", async () => {
|
|
const onWorkspaceScopeChange = vi.fn();
|
|
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
|
|
const defaultScope = {
|
|
project_path: "/Users/test/.nanobot/workspace",
|
|
project_name: "workspace",
|
|
access_mode: "full" as const,
|
|
restrict_to_workspace: false,
|
|
};
|
|
Object.defineProperty(window, "nanobotHost", {
|
|
configurable: true,
|
|
value: {
|
|
getRuntimeInfo: vi.fn(),
|
|
restartEngine: vi.fn(),
|
|
pickFolder,
|
|
openLogs: vi.fn(),
|
|
exportDiagnostics: vi.fn(),
|
|
},
|
|
});
|
|
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Ask anything..."
|
|
variant="hero"
|
|
workspaceScope={defaultScope}
|
|
workspaceDefaultScope={defaultScope}
|
|
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
|
|
|
|
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
|
|
expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument();
|
|
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
project_path: "/Users/test/native-project",
|
|
project_name: "native-project",
|
|
access_mode: "full",
|
|
restrict_to_workspace: false,
|
|
}));
|
|
});
|
|
|
|
it("uses the web path menu when no native host picker is available", async () => {
|
|
const defaultScope = {
|
|
project_path: "/Users/test/.nanobot/workspace",
|
|
project_name: "workspace",
|
|
access_mode: "full" as const,
|
|
restrict_to_workspace: false,
|
|
};
|
|
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Ask anything..."
|
|
variant="hero"
|
|
workspaceScope={defaultScope}
|
|
workspaceDefaultScope={defaultScope}
|
|
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
|
onWorkspaceScopeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
|
|
|
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows turn run timer when runStartedAt is set", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date((1_000 + 125) * 1000));
|
|
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
runStartedAt={1000}
|
|
/>,
|
|
);
|
|
|
|
const status = screen.getByRole("status");
|
|
expect(status).toHaveTextContent(/Running/);
|
|
expect(status).toHaveTextContent(/2:05/);
|
|
expect(status.parentElement).toHaveClass("composer-status-strip");
|
|
expect(status.parentElement).toHaveAttribute("data-state", "enter");
|
|
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
|
|
const longObjective =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail";
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
goalState={{
|
|
active: true,
|
|
objective: longObjective,
|
|
ui_summary: "Short summary for strip",
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Show full goal" }));
|
|
|
|
const dialog = await screen.findByRole("dialog", { name: "Goal" });
|
|
expect(dialog).toBeInTheDocument();
|
|
expect(dialog).toHaveTextContent("Short summary for strip");
|
|
expect(dialog).toHaveTextContent(longObjective);
|
|
});
|
|
|
|
it("opens a slash command palette and inserts the selected command", () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
placeholder="Type your message..."
|
|
slashCommands={COMMANDS}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "/" } });
|
|
|
|
const palette = screen.getByRole("listbox", { name: "Slash commands" });
|
|
expect(palette).toBeInTheDocument();
|
|
expect(palette).toHaveStyle({ maxHeight: "288px" });
|
|
expect(screen.queryByRole("option", { name: /\/stop/i })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(input).toHaveValue("/history ");
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders slash commands as direct actions with current status", () => {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
modelLabel="deepseek-v4-pro"
|
|
slashCommands={[
|
|
{
|
|
command: "/model",
|
|
title: "Switch model preset",
|
|
description: "Show or switch the active model preset.",
|
|
icon: "brain",
|
|
argHint: "[preset]",
|
|
},
|
|
COMMANDS[1],
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "/" },
|
|
});
|
|
|
|
expect(screen.getByRole("option", { name: /Model deepseek-v4-pro/i })).toBeInTheDocument();
|
|
expect(screen.getByText("Current")).toBeInTheDocument();
|
|
expect(screen.getByText("/model [preset]")).toBeInTheDocument();
|
|
});
|
|
|
|
it("prioritizes stop as an immediate slash action while streaming", () => {
|
|
const onStop = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
onStop={onStop}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
slashCommands={[COMMANDS[1]]}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "/" } });
|
|
|
|
expect(screen.getByRole("option", { name: /Stop current task/i })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(onStop).toHaveBeenCalledTimes(1);
|
|
expect(input).toHaveValue("");
|
|
expect(window.localStorage.getItem("nanobot.webui.slashCommandRecents")).toBeNull();
|
|
});
|
|
|
|
it("orders recent slash commands first for the blank slash menu", () => {
|
|
window.localStorage.setItem("nanobot.webui.slashCommandRecents", JSON.stringify(["/history"]));
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
slashCommands={COMMANDS}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "/" },
|
|
});
|
|
|
|
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
expect(screen.getByText("Recent")).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps keyboard-selected slash options visible while navigating", () => {
|
|
const scrollIntoView = vi.fn();
|
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
|
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
|
|
|
try {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
slashCommands={Array.from({ length: 8 }, (_, index) => ({
|
|
command: `/cmd-${index}`,
|
|
title: `Command ${index}`,
|
|
description: `Description ${index}`,
|
|
icon: "activity",
|
|
}))}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "/" } });
|
|
scrollIntoView.mockClear();
|
|
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
|
|
expect(screen.getByRole("option", { name: /\/cmd-2/i })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" });
|
|
} finally {
|
|
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
|
}
|
|
});
|
|
|
|
it("opens the CLI app mention palette and inserts the selected app", () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
placeholder="Type your message..."
|
|
cliApps={CLI_APPS}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
|
|
|
const palette = screen.getByRole("listbox", { name: "Apps" });
|
|
expect(palette).toBeInTheDocument();
|
|
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
expect(screen.queryByRole("option", { name: /@krita/i })).not.toBeInTheDocument();
|
|
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
expect(screen.getByRole("option", { name: /@blender/i })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(input).toHaveValue("@blender ");
|
|
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
|
|
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
|
|
expect(onSend).toHaveBeenCalledWith("@blender", undefined, {
|
|
cliApps: [{
|
|
name: "blender",
|
|
display_name: "Blender",
|
|
category: "3d",
|
|
entry_point: "cli-anything-blender",
|
|
logo_url: null,
|
|
brand_color: "#E87D0D",
|
|
}],
|
|
});
|
|
});
|
|
|
|
it("keeps keyboard-selected mention options visible while navigating", () => {
|
|
const scrollIntoView = vi.fn();
|
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
|
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
|
|
|
try {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
cliApps={Array.from({ length: 8 }, (_, index) => ({
|
|
name: `app-${index}`,
|
|
display_name: `App ${index}`,
|
|
category: "test",
|
|
description: "Test app",
|
|
requires: "",
|
|
source: "harness",
|
|
entry_point: `app-${index}`,
|
|
install_supported: true,
|
|
installed: true,
|
|
available: true,
|
|
status: "installed",
|
|
logo_url: null,
|
|
brand_color: "#111827",
|
|
skill_installed: true,
|
|
}))}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
|
scrollIntoView.mockClear();
|
|
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
|
|
expect(screen.getByRole("option", { name: /@app-2/i })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" });
|
|
} finally {
|
|
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
|
}
|
|
});
|
|
|
|
it("completes a CLI app mention with Tab and adds exactly one trailing space", () => {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
cliApps={CLI_APPS}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, {
|
|
target: { value: "use @ble", selectionStart: 8 },
|
|
});
|
|
|
|
fireEvent.keyDown(input, { key: "Tab" });
|
|
|
|
expect(input).toHaveValue("use @blender ");
|
|
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
|
|
});
|
|
|
|
it("shows configured MCP presets in the mention palette and submits metadata", () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
placeholder="Type your message..."
|
|
cliApps={CLI_APPS}
|
|
mcpPresets={MCP_PRESETS}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, {
|
|
target: { value: "use @bro", selectionStart: 8 },
|
|
});
|
|
|
|
expect(screen.getByRole("option", { name: /@browserbase/i })).toBeInTheDocument();
|
|
expect(screen.queryByRole("option", { name: /@figma/i })).not.toBeInTheDocument();
|
|
|
|
fireEvent.keyDown(input, { key: "Tab" });
|
|
|
|
expect(input).toHaveValue("use @browserbase ");
|
|
expect(screen.getByTestId("composer-mcp-mention-browserbase")).toHaveTextContent("@browserbase");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
|
|
expect(onSend).toHaveBeenCalledWith("use @browserbase", undefined, {
|
|
mcpPresets: [{
|
|
name: "browserbase",
|
|
display_name: "Browserbase",
|
|
category: "browser",
|
|
transport: "streamableHttp",
|
|
status: "configured",
|
|
configured: true,
|
|
logo_url: "https://example.invalid/browserbase.svg",
|
|
brand_color: "#111827",
|
|
}],
|
|
});
|
|
});
|
|
|
|
it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
cliApps={CLI_APPS}
|
|
mcpPresets={MCP_PRESETS}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
|
|
|
expect(screen.queryByText("CLI Apps")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("MCP servers")).not.toBeInTheDocument();
|
|
const gimp = screen.getByRole("option", { name: /GIMP @gimp .* CLI/i });
|
|
const browserbase = screen.getByRole("option", { name: /Browserbase @browserbase .* MCP/i });
|
|
expect(within(gimp).getByText("CLI")).toBeInTheDocument();
|
|
expect(within(browserbase).getByText("MCP")).toBeInTheDocument();
|
|
expect(within(gimp).getByText("@gimp")).toBeInTheDocument();
|
|
expect(within(browserbase).getByText("@browserbase")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not duplicate the next word separator when completing a CLI app mention", () => {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
cliApps={CLI_APPS}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, {
|
|
target: { value: "use @ble tonight", selectionStart: 8 },
|
|
});
|
|
|
|
fireEvent.keyDown(input, { key: "Tab" });
|
|
|
|
expect(input).toHaveValue("use @blender tonight");
|
|
});
|
|
|
|
it("renders a CLI app mention logo inline without moving the text cursor slot", () => {
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
cliApps={CLI_APPS}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, {
|
|
target: { value: "meeting in @gimp", selectionStart: 16 },
|
|
});
|
|
|
|
expect(input).toHaveValue("meeting in @gimp");
|
|
const token = screen.getByTestId("composer-cli-mention-gimp");
|
|
expect(token).toHaveTextContent("@gimp");
|
|
expect(token.className).not.toContain("font-semibold");
|
|
expect(token.className).not.toContain("zoom-in");
|
|
expect(token.className).not.toContain("px-");
|
|
expect(token.className).not.toContain("mx-");
|
|
expect(token.getAttribute("style")).toContain("color: #5C5543");
|
|
expect(token.getAttribute("style")).toContain("text-shadow");
|
|
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
|
|
const logo = screen.getByTestId("composer-cli-mention-logo-gimp");
|
|
expect(logo.className).toContain("top-1/2");
|
|
expect(logo.className).toContain("left-1/2");
|
|
expect(logo.className).not.toContain("-top-");
|
|
});
|
|
|
|
it("opens the slash command palette downward when there is more room below", async () => {
|
|
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
|
|
rect({ top: 40, bottom: 160, width: 800, height: 120 }),
|
|
);
|
|
Object.defineProperty(window, "innerHeight", {
|
|
value: 330,
|
|
configurable: true,
|
|
});
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Ask anything..."
|
|
slashCommands={COMMANDS}
|
|
variant="hero"
|
|
/>,
|
|
);
|
|
const input = screen.getByLabelText("Message input");
|
|
|
|
fireEvent.change(input, { target: { value: "/" } });
|
|
|
|
await waitFor(() => {
|
|
const palette = screen.getByRole("listbox", { name: "Slash commands" });
|
|
expect(palette.className).toContain("top-full");
|
|
expect(palette).toHaveStyle({ maxHeight: "162px" });
|
|
});
|
|
});
|
|
|
|
it("dismisses the slash command palette on outside click", () => {
|
|
render(
|
|
<div>
|
|
<button type="button">outside</button>
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
placeholder="Type your message..."
|
|
slashCommands={COMMANDS}
|
|
/>
|
|
</div>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "/" },
|
|
});
|
|
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
|
|
|
|
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
|
|
|
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps image generation mode out of the composer chrome", () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Image aspect ratio" })).not.toBeInTheDocument();
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "Draw a friendly robot" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
|
|
expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined);
|
|
});
|
|
|
|
it("shows a stop button while streaming", () => {
|
|
const onStop = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={vi.fn()}
|
|
onStop={onStop}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Stop response" }));
|
|
|
|
expect(onStop).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("queues plain guidance while a task is running", () => {
|
|
const onSend = vi.fn();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "keep the UI minimal" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
expect(input).toHaveValue("");
|
|
expect(screen.getByText("keep the UI minimal")).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
|
|
|
|
expect(onSend).toHaveBeenCalledWith("keep the UI minimal");
|
|
expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps queued guidance attached to the composer and sends it one item at a time", async () => {
|
|
const onSend = vi.fn();
|
|
const { rerender } = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
const queue = screen.getByRole("group", { name: "Queued guidance" });
|
|
expect(queue).toHaveClass("composer-status-strip");
|
|
expect(queue).toHaveClass("mx-3");
|
|
expect(queue.parentElement?.className).toContain("group/composer");
|
|
expect(within(queue).getByText("first follow-up")).toBeInTheDocument();
|
|
expect(within(queue).getByText("second follow-up")).toBeInTheDocument();
|
|
expect(within(queue).getAllByRole("button", { name: "Edit guidance" })).toHaveLength(2);
|
|
expect(within(queue).getAllByRole("button", { name: "Guide" })).toHaveLength(2);
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenCalledWith("first follow-up");
|
|
});
|
|
expect(onSend).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByText("first follow-up")).not.toBeInTheDocument();
|
|
expect(screen.getByText("second follow-up")).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenLastCalledWith("second follow-up");
|
|
});
|
|
expect(onSend).toHaveBeenCalledTimes(2);
|
|
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("lets users edit queued guidance before it is sent", async () => {
|
|
const onSend = vi.fn();
|
|
const { rerender } = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "rough follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
const editButton = screen.getByRole("button", { name: "Edit guidance" });
|
|
fireEvent.click(editButton);
|
|
await waitFor(() => {
|
|
expect(input).toHaveFocus();
|
|
});
|
|
expect(input).toHaveValue("rough follow-up");
|
|
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
|
|
fireEvent.change(input, { target: { value: "polished follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenCalledWith("polished follow-up");
|
|
});
|
|
});
|
|
|
|
it("requeues edited guidance at the end of the pending list", async () => {
|
|
const onSend = vi.fn();
|
|
const { rerender } = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
fireEvent.click(screen.getAllByRole("button", { name: "Edit guidance" })[0]);
|
|
await waitFor(() => {
|
|
expect(input).toHaveValue("first follow-up");
|
|
});
|
|
fireEvent.change(input, { target: { value: "first follow-up edited" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenCalledWith("second follow-up");
|
|
});
|
|
expect(onSend).toHaveBeenCalledTimes(1);
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenLastCalledWith("first follow-up edited");
|
|
});
|
|
expect(onSend).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("queues image guidance while running and restores it for editing", async () => {
|
|
mockBlobUrls();
|
|
const onSend = vi.fn();
|
|
const { container, rerender } = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]');
|
|
expect(fileInput).toBeTruthy();
|
|
const file = new File(["image"], "draft.png", { type: "image/png" });
|
|
fireEvent.change(fileInput!, { target: { files: [file] } });
|
|
await screen.findByText("draft.png");
|
|
|
|
fireEvent.change(input, { target: { value: "look at this" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
expect(onSend).not.toHaveBeenCalled();
|
|
expect(screen.getByRole("group", { name: "Queued guidance" })).toBeInTheDocument();
|
|
expect(screen.getByText("look at this")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
|
|
expect(input).toHaveValue("look at this");
|
|
expect(screen.getByTestId("composer-chip")).toHaveTextContent("draft.png");
|
|
expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument();
|
|
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenCalledWith(
|
|
"look at this",
|
|
[expect.objectContaining({
|
|
media: expect.objectContaining({
|
|
data_url: "data:image/png;base64,aW1hZ2U=",
|
|
name: "draft.png",
|
|
}),
|
|
})],
|
|
);
|
|
});
|
|
});
|
|
|
|
it("reorders queued guidance while dragging over another row", async () => {
|
|
const onSend = vi.fn();
|
|
const { rerender } = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
const handles = screen.getAllByLabelText("Drag to reorder");
|
|
const secondRow = screen
|
|
.getByText("second follow-up")
|
|
.closest("[data-queued-prompt-row='true']");
|
|
expect(secondRow).toBeTruthy();
|
|
|
|
const dataTransfer = {
|
|
effectAllowed: "",
|
|
dropEffect: "",
|
|
setData: vi.fn(),
|
|
getData: vi.fn(),
|
|
};
|
|
fireEvent.dragStart(handles[0], { dataTransfer });
|
|
fireEvent.dragEnter(secondRow!, { dataTransfer });
|
|
fireEvent.dragEnd(handles[0], { dataTransfer });
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenCalledWith("second follow-up");
|
|
});
|
|
});
|
|
|
|
it("moves later queued guidance before an earlier item while dragging", async () => {
|
|
const onSend = vi.fn();
|
|
const { rerender } = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "first follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
fireEvent.change(input, { target: { value: "second follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
const handles = screen.getAllByLabelText("Drag to reorder");
|
|
const firstRow = screen
|
|
.getByText("first follow-up")
|
|
.closest("[data-queued-prompt-row='true']");
|
|
expect(firstRow).toBeTruthy();
|
|
|
|
const dataTransfer = {
|
|
effectAllowed: "",
|
|
dropEffect: "",
|
|
setData: vi.fn(),
|
|
getData: vi.fn(),
|
|
};
|
|
fireEvent.dragStart(handles[1], { dataTransfer });
|
|
fireEvent.dragEnter(firstRow!, { dataTransfer });
|
|
fireEvent.dragEnd(handles[1], { dataTransfer });
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming={false}
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onSend).toHaveBeenCalledWith("second follow-up");
|
|
});
|
|
});
|
|
|
|
it("persists queued guidance per chat across remounts", async () => {
|
|
const onSend = vi.fn();
|
|
const { rerender, unmount } = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
pendingQueueKey="chat-a"
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message input");
|
|
fireEvent.change(input, { target: { value: "remember this follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
fireEvent.click(screen.getByRole("button", { name: "Edit guidance" }));
|
|
fireEvent.change(input, { target: { value: "remember this edited follow-up" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
expect(screen.getByText("remember this edited follow-up")).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
pendingQueueKey="chat-b"
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
|
|
});
|
|
|
|
unmount();
|
|
const remount = render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
pendingQueueKey="chat-a"
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "Guide" }));
|
|
expect(onSend).toHaveBeenCalledWith("remember this edited follow-up");
|
|
|
|
remount.unmount();
|
|
render(
|
|
<ThreadComposer
|
|
onSend={onSend}
|
|
onStop={vi.fn()}
|
|
isStreaming
|
|
pendingQueueKey="chat-a"
|
|
placeholder="Type your message..."
|
|
/>,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
});
|