feat(webui): highlight slash commands and app mentions (#4933)

This commit is contained in:
chengyongru
2026-07-15 00:34:22 +08:00
committed by GitHub
parent 2116e32013
commit 37165b0db0
15 changed files with 459 additions and 88 deletions
+98 -1
View File
@@ -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 = {
+36
View File
@@ -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 }),
+63
View File
@@ -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",
);
});
});