feat(webui): highlight slash commands and app mentions (#4933)
This commit is contained in:
@@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
|
||||
const CLI_APPS: CliAppInfo[] = [
|
||||
{
|
||||
@@ -61,6 +61,33 @@ const MCP_PRESETS: McpPresetInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
command: "/model",
|
||||
title: "Show or switch model",
|
||||
description: "Show the active model or switch to another configuration.",
|
||||
icon: "brain",
|
||||
lifecycle: "agent_turn_with_args",
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
command: "/goal",
|
||||
title: "Start a goal",
|
||||
description: "Start a sustained goal.",
|
||||
icon: "activity",
|
||||
lifecycle: "agent_turn_with_args",
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
command: "/new",
|
||||
title: "New chat",
|
||||
description: "Start a new chat.",
|
||||
icon: "square-pen",
|
||||
lifecycle: "finalize_active_turn",
|
||||
acceptsArgs: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("renders user messages as right-aligned pills", () => {
|
||||
const message: UIMessage = {
|
||||
@@ -107,6 +134,76 @@ describe("MessageBubble", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("highlights recognized slash command names without adding container chrome", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-command",
|
||||
role: "user",
|
||||
content: "/model gpt-5",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} slashCommands={SLASH_COMMANDS} />);
|
||||
|
||||
const command = screen.getByTestId("message-slash-command");
|
||||
expect(command).toHaveTextContent("/model");
|
||||
expect(command).toHaveClass(
|
||||
"font-medium",
|
||||
"transition-[color,text-shadow]",
|
||||
"duration-150",
|
||||
);
|
||||
expect(command).not.toHaveClass("font-mono", "font-semibold");
|
||||
expect(command.getAttribute("style")).toContain("text-shadow");
|
||||
expect(command.getAttribute("style")).toContain("var(--inline-token-highlight)");
|
||||
expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
|
||||
expect(command.parentElement).toHaveTextContent("/model gpt-5");
|
||||
expect(command.parentElement).toHaveClass("rounded-[18px]", "bg-secondary/70");
|
||||
});
|
||||
|
||||
it("keeps unknown and invalid slash commands as plain message text", () => {
|
||||
const unknown: UIMessage = {
|
||||
id: "u-unknown-command",
|
||||
role: "user",
|
||||
content: "/unknown value",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const invalidExactCommand: UIMessage = {
|
||||
id: "u-invalid-command",
|
||||
role: "user",
|
||||
content: "/new with-arguments",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<MessageBubble message={unknown} slashCommands={SLASH_COMMANDS} />,
|
||||
);
|
||||
expect(screen.queryByTestId("message-slash-command")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("/unknown value")).toBeInTheDocument();
|
||||
|
||||
rerender(<MessageBubble message={invalidExactCommand} slashCommands={SLASH_COMMANDS} />);
|
||||
expect(screen.queryByTestId("message-slash-command")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("/new with-arguments")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves installed capability mentions in slash command arguments", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-command-mention",
|
||||
role: "user",
|
||||
content: "/goal ask @zoom to schedule the review",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
render(
|
||||
<MessageBubble
|
||||
message={message}
|
||||
slashCommands={SLASH_COMMANDS}
|
||||
cliApps={CLI_APPS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("message-slash-command")).toHaveTextContent("/goal");
|
||||
expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
|
||||
});
|
||||
|
||||
it("renders fork control in completed assistant action rows", () => {
|
||||
const onForkFromHere = vi.fn();
|
||||
const message: UIMessage = {
|
||||
|
||||
@@ -1248,6 +1248,42 @@ describe("ThreadComposer", () => {
|
||||
expect(logo.className).not.toContain("-top-");
|
||||
});
|
||||
|
||||
it("uses the shared accent when an installed CLI app has no brand metadata", () => {
|
||||
const mention = "@obsidian-agent-cli";
|
||||
const app: CliAppInfo = {
|
||||
name: "obsidian-agent-cli",
|
||||
display_name: "Obsidian CLI",
|
||||
category: "productivity",
|
||||
description: "Obsidian automation",
|
||||
requires: "",
|
||||
source: "local",
|
||||
entry_point: "obsidian-agent",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: null,
|
||||
brand_color: null,
|
||||
skill_installed: true,
|
||||
};
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
cliApps={[app]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: mention, selectionStart: mention.length },
|
||||
});
|
||||
|
||||
const token = screen.getByTestId("composer-cli-mention-obsidian-agent-cli");
|
||||
expect(token.getAttribute("style")).toContain("var(--inline-token-highlight)");
|
||||
expect(token.getAttribute("style")).not.toContain("var(--primary)");
|
||||
});
|
||||
|
||||
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 }),
|
||||
|
||||
@@ -1587,4 +1587,67 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByRole("listbox", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps installed app mentions available during transient catalog refresh failures", async () => {
|
||||
const client = makeClient();
|
||||
const payload: CliAppsPayload = {
|
||||
apps: [{
|
||||
name: "obsidian-agent-cli",
|
||||
display_name: "Obsidian",
|
||||
category: "productivity",
|
||||
description: "Obsidian automation",
|
||||
requires: "",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-obsidian",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: null,
|
||||
brand_color: "#7C3AED",
|
||||
skill_installed: true,
|
||||
}],
|
||||
installed_count: 1,
|
||||
catalog_updated_at: "2026-07-14",
|
||||
};
|
||||
vi.mocked(fetch).mockImplementation(async (input) => {
|
||||
if (String(input).includes("/api/settings/cli-apps?installed_only=1")) {
|
||||
throw new Error("temporary catalog failure");
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-cli-refresh")}
|
||||
title="Chat chat-cli-refresh"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent(CLI_APPS_CHANGED_EVENT, { detail: payload }));
|
||||
});
|
||||
const mention = "@obsidian-agent-cli";
|
||||
fireEvent.change(input, { target: { value: mention, selectionStart: mention.length } });
|
||||
expect(screen.getByRole("option", { name: /@obsidian-agent-cli/i })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("option", { name: /@obsidian-agent-cli/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("composer-cli-mention-obsidian-agent-cli")).toHaveTextContent(
|
||||
"@obsidian-agent-cli",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user