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
+28 -4
View File
@@ -188,6 +188,8 @@ _BRAND_ALIASES: dict[str, str] = {
"lark-cli": "feishu",
"minimax-cli": "minimax",
"obsidian-cli": "obsidian",
"obsidian-agent": "obsidian",
"obsidian-agent-cli": "obsidian",
"slay-the-spire-2": "slay-the-spire-ii",
"slay-the-spire-ii": "slay-the-spire-ii",
"unimol-tools": "unimol-tools",
@@ -761,19 +763,30 @@ class CliAppManager:
def installed_payload(self) -> dict[str, Any]:
installed = self._load_installed()
cached_apps, _ = self.catalog(cache_only=True)
cached_by_name = {
str(app.get("name") or "").lower(): app
for app in cached_apps
if app.get("name")
}
rows = []
for name, raw_entry in sorted(installed.items()):
entry = raw_entry if isinstance(raw_entry, dict) else {}
strategy = str(entry.get("strategy") or "bundled")
cached_app = cached_by_name.get(str(name).lower(), {})
app = {
"name": str(name),
"display_name": str(entry.get("display_name") or name),
"category": str(entry.get("category") or "installed"),
"description": str(entry.get("description") or ""),
"requires": str(entry.get("requires") or ""),
"display_name": str(
cached_app.get("display_name") or entry.get("display_name") or name
),
"category": str(cached_app.get("category") or entry.get("category") or "installed"),
"description": str(cached_app.get("description") or entry.get("description") or ""),
"requires": str(cached_app.get("requires") or entry.get("requires") or ""),
"_source": str(entry.get("source") or "local"),
"entry_point": str(entry.get("entry_point") or ""),
"package_manager": strategy,
"logo_url": cached_app.get("logo_url") or entry.get("logo_url"),
"brand_color": cached_app.get("brand_color") or entry.get("brand_color"),
}
rows.append(self._app_payload(app, installed))
return {
@@ -966,6 +979,17 @@ class CliAppManager:
"strategy": strategy,
"installed_at": int(_now()),
}
for field in (
"display_name",
"category",
"description",
"requires",
"logo_url",
"brand_color",
):
value = app.get(field)
if value not in (None, ""):
entry[field] = value
resolved = shutil.which(entry_point) if entry_point else None
if resolved:
entry["entry_point_path"] = resolved
+60
View File
@@ -197,6 +197,63 @@ def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(
manager._cache_path("harness"),
{
"meta": {"updated": "2026-07-14"},
"clis": [
{
"name": "obsidian-agent-cli",
"display_name": "Obsidian CLI",
"description": "Obsidian automation",
"category": "productivity",
"entry_point": "obsidian-agent",
}
],
},
)
_write_cache(manager._cache_path("public"), {"meta": {}, "clis": []})
_write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []})
app = manager.payload()["apps"][0]
assert app["brand_color"] == "#7C3AED"
assert app["logo_url"] == "https://cdn.simpleicons.org/obsidian/7C3AED"
def test_installed_payload_enriches_apps_from_cached_catalog(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({
"gimp": {
"entry_point": "installed-gimp",
"source": "harness",
"strategy": "pip",
}
})
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry_point: "/bin/installed-gimp" if entry_point == "installed-gimp" else None,
)
app = manager.installed_payload()["apps"][0]
assert app["name"] == "gimp"
assert app["entry_point"] == "installed-gimp"
assert app["source"] == "harness"
assert app["status"] == "installed"
assert app["display_name"] == "GIMP"
assert app["category"] == "image"
assert app["description"] == "Public duplicate entry"
assert app["brand_color"] == "#5C5543"
assert app["logo_url"] == "https://cdn.simpleicons.org/gimp/5C5543"
def test_payload_includes_nanobot_extension_registry(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
@@ -520,6 +577,9 @@ def test_install_records_entry_point_path_and_pip_distribution(
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point_path"] == str(resolved)
assert installed["gimp"]["pip_distribution"] == "cli-anything-gimp"
assert installed["gimp"]["display_name"] == "GIMP"
assert installed["gimp"]["category"] == "image"
assert installed["gimp"]["description"] == "Public duplicate entry"
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
+14 -30
View File
@@ -1,5 +1,9 @@
import { useMemo } from "react";
import {
INLINE_TOKEN_HIGHLIGHT_COLOR,
InlineTokenHighlight,
} from "@/components/InlineTokenHighlight";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
@@ -24,7 +28,6 @@ export function cliAppInitials(app: CliAppInfo): string {
.join("") || app.name.slice(0, 2).toUpperCase()
);
}
export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_name">): string {
const value = preset.display_name || preset.name;
return (
@@ -36,7 +39,6 @@ export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_
.join("") || preset.name.slice(0, 2).toUpperCase()
);
}
export function splitCapabilityMentionSegments(
value: string,
cliApps: CliAppInfo[],
@@ -138,7 +140,7 @@ export function CliAppMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
const color = app.brand_color || "hsl(var(--primary))";
const color = app.brand_color || INLINE_TOKEN_HIGHLIGHT_COLOR;
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
@@ -146,14 +148,10 @@ export function CliAppMentionToken({
const testIdPrefix = variant === "composer" ? "composer" : "message";
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
<InlineTokenHighlight
testId={`${testIdPrefix}-cli-mention-${app.name}`}
title={`CLI app: ${app.display_name || app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
color={color}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
@@ -182,7 +180,7 @@ export function CliAppMentionToken({
) : null}
</span>
{mentionName}
</span>
</InlineTokenHighlight>
);
}
@@ -197,7 +195,7 @@ export function McpPresetMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
const color = preset.brand_color || "hsl(var(--primary))";
const color = preset.brand_color || INLINE_TOKEN_HIGHLIGHT_COLOR;
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
@@ -205,14 +203,10 @@ export function McpPresetMentionToken({
const testIdPrefix = variant === "composer" ? "composer" : "message";
return (
<span
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
<InlineTokenHighlight
testId={`${testIdPrefix}-mcp-mention-${preset.name}`}
title={`MCP server: ${preset.display_name || preset.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
color={color}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
@@ -241,16 +235,6 @@ export function McpPresetMentionToken({
) : null}
</span>
{mentionName}
</span>
</InlineTokenHighlight>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export const INLINE_TOKEN_HIGHLIGHT_COLOR = "hsl(var(--inline-token-highlight))";
export function InlineTokenHighlight({
children,
className,
color,
testId,
title,
}: {
children: ReactNode;
className?: string;
color: string;
testId?: string;
title?: string;
}) {
return (
<span
data-testid={testId}
title={title}
className={cn(
"relative inline transition-[color,text-shadow] duration-150",
className,
)}
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
{children}
</span>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
+23 -5
View File
@@ -21,6 +21,7 @@ import { AttachmentTile } from "@/components/AttachmentTile";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { SlashCommandText } from "@/components/SlashCommandText";
import {
Tooltip,
TooltipContent,
@@ -31,9 +32,11 @@ import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command";
import type {
CliAppInfo,
McpPresetInfo,
SlashCommand,
UICliAppAttachment,
UIMcpPresetAttachment,
UIImage,
@@ -47,6 +50,7 @@ interface MessageBubbleProps {
showCopyAction?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
onOpenFilePreview?: (path: string) => void;
onForkFromHere?: () => void;
}
@@ -138,6 +142,7 @@ export function MessageBubble({
showCopyAction = true,
cliApps = [],
mcpPresets = [],
slashCommands = [],
onOpenFilePreview,
onForkFromHere,
}: MessageBubbleProps) {
@@ -162,6 +167,23 @@ export function MessageBubble({
const hasImages = images.length > 0;
const hasMedia = media.length > 0;
const hasText = message.content.trim().length > 0;
const slashCommand = matchingSlashCommand(message.content, slashCommands);
const messageText = slashCommand ? (
<>
<SlashCommandText command={slashCommand.command} />
<CliAppMentionText
text={message.content.slice(slashCommand.command.length)}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
/>
</>
) : (
<CliAppMentionText
text={message.content}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
/>
);
return (
<div
className={cn(
@@ -180,11 +202,7 @@ export function MessageBubble({
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
)}
>
<CliAppMentionText
text={message.content}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
/>
{messageText}
</p>
) : null}
{hasText && showCopyAction ? (
+22
View File
@@ -0,0 +1,22 @@
import {
INLINE_TOKEN_HIGHLIGHT_COLOR,
InlineTokenHighlight,
} from "@/components/InlineTokenHighlight";
interface SlashCommandTextProps {
command: string;
}
export function SlashCommandText({
command,
}: SlashCommandTextProps) {
return (
<InlineTokenHighlight
testId="message-slash-command"
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
className="font-medium"
>
{command}
</InlineTokenHighlight>
);
}
+6 -45
View File
@@ -18,6 +18,7 @@ import {
splitCapabilityMentionSegments,
type CapabilityMentionSegment,
} from "@/components/CliAppMentionText";
import { INLINE_TOKEN_HIGHLIGHT_COLOR } from "@/components/InlineTokenHighlight";
import {
Activity,
ArrowUp,
@@ -88,55 +89,15 @@ import {
logoFallbackUrls,
providerBrand,
} from "@/lib/provider-brand";
import {
isSideChannelLifecycle,
slashCommandLifecycle,
} from "@/lib/slash-command";
import { cn } from "@/lib/utils";
const VOICE_SHORTCUT_CODE = "KeyD";
const VOICE_SHORTCUT_ARIA = "Control+Shift+D";
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
type ResolvedSlashCommandLifecycle =
| "side_channel"
| "finalize_active_turn"
| "stop_active_turn"
| "agent_turn";
function slashCommandName(content: string): string {
return content.split(/\s+/, 1)[0];
}
function slashCommandArgs(content: string, commandName: string): string {
return content.slice(commandName.length).trim();
}
function matchingSlashCommand(content: string, slashCommands: SlashCommand[]): SlashCommand | null {
const commandName = slashCommandName(content);
if (!commandName.startsWith("/")) return null;
const command = slashCommands.find((item) => item.command === commandName);
if (!command) return null;
if (slashCommandArgs(content, command.command).length > 0 && !command.acceptsArgs) return null;
return command;
}
function slashCommandLifecycle(
content: string,
slashCommands: SlashCommand[],
): ResolvedSlashCommandLifecycle | null {
const command = matchingSlashCommand(content, slashCommands);
if (!command) return null;
if (command.lifecycle === "agent_turn_with_args") {
return slashCommandArgs(content, command.command).length > 0
? "agent_turn"
: "side_channel";
}
return command.lifecycle;
}
function isSideChannelLifecycle(lifecycle: ResolvedSlashCommandLifecycle | null): boolean {
return (
lifecycle === "side_channel"
|| lifecycle === "finalize_active_turn"
|| lifecycle === "stop_active_turn"
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
@@ -2553,7 +2514,7 @@ function MentionCandidateLogo({
}) {
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.preset.brand_color) || "hsl(var(--primary))";
: candidate.preset.brand_color) || INLINE_TOKEN_HIGHLIGHT_COLOR;
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -12,6 +12,7 @@ interface ThreadMessagesProps {
hiddenUserMessageCount?: number;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
@@ -51,6 +52,7 @@ export function ThreadMessages({
hiddenUserMessageCount = 0,
cliApps = [],
mcpPresets = [],
slashCommands = [],
forkBoundaryMessageCount = null,
onOpenFilePreview,
onForkFromMessage,
@@ -116,6 +118,7 @@ export function ThreadMessages({
}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={
onForkFromMessage && forkIndex !== undefined
+2 -1
View File
@@ -238,7 +238,7 @@ function useInstalledSettingItems<Payload, Item>({
const payload = await fetchPayload(token);
if (!isCancelled?.()) setItems(selectItems(payload));
} catch {
if (!isCancelled?.()) setItems([]);
// Keep the last successful catalog during transient focus/visibility refresh failures.
}
}, [fetchPayload, selectItems, token]);
@@ -842,6 +842,7 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
@@ -22,7 +22,7 @@ import {
promptTop,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
@@ -40,6 +40,7 @@ interface ThreadViewportProps {
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean;
loadingOlder?: boolean;
@@ -111,6 +112,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
slashCommands = [],
forkBoundaryMessageCount = null,
hasMoreBefore = false,
loadingOlder = false,
@@ -526,6 +528,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
+2
View File
@@ -25,6 +25,7 @@
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--inline-token-highlight: 221 70% 50%;
--radius: 0.4375rem;
--sidebar: 0 0% 98.5%;
--sidebar-foreground: 0 0% 3.9%;
@@ -56,6 +57,7 @@
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 0 0% 83.1%;
--inline-token-highlight: 217 92% 72%;
--sidebar: 0 0% 11.5%;
--sidebar-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 15.5%;
+51
View File
@@ -0,0 +1,51 @@
import type { SlashCommand } from "@/lib/types";
type ResolvedSlashCommandLifecycle =
| "side_channel"
| "finalize_active_turn"
| "stop_active_turn"
| "agent_turn";
function slashCommandName(content: string): string {
return content.split(/\s+/, 1)[0];
}
function slashCommandArgs(content: string, commandName: string): string {
return content.slice(commandName.length).trim();
}
export function matchingSlashCommand(
content: string,
slashCommands: SlashCommand[],
): SlashCommand | null {
const commandName = slashCommandName(content);
if (!commandName.startsWith("/")) return null;
const command = slashCommands.find((item) => item.command === commandName);
if (!command) return null;
if (slashCommandArgs(content, command.command).length > 0 && !command.acceptsArgs) return null;
return command;
}
export function slashCommandLifecycle(
content: string,
slashCommands: SlashCommand[],
): ResolvedSlashCommandLifecycle | null {
const command = matchingSlashCommand(content, slashCommands);
if (!command) return null;
if (command.lifecycle === "agent_turn_with_args") {
return slashCommandArgs(content, command.command).length > 0
? "agent_turn"
: "side_channel";
}
return command.lifecycle;
}
export function isSideChannelLifecycle(
lifecycle: ResolvedSlashCommandLifecycle | null,
): boolean {
return (
lifecycle === "side_channel"
|| lifecycle === "finalize_active_turn"
|| lifecycle === "stop_active_turn"
);
}
+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",
);
});
});