From 37165b0db06a659752e28b9015a4166b509320a2 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:34:22 +0800 Subject: [PATCH] feat(webui): highlight slash commands and app mentions (#4933) --- nanobot/apps/cli/service.py | 32 +++++- tests/cli_apps/test_service.py | 60 +++++++++++ webui/src/components/CliAppMentionText.tsx | 44 +++------ webui/src/components/InlineTokenHighlight.tsx | 46 +++++++++ webui/src/components/MessageBubble.tsx | 28 +++++- webui/src/components/SlashCommandText.tsx | 22 +++++ .../src/components/thread/ThreadComposer.tsx | 51 ++-------- .../src/components/thread/ThreadMessages.tsx | 5 +- webui/src/components/thread/ThreadShell.tsx | 3 +- .../src/components/thread/ThreadViewport.tsx | 5 +- webui/src/globals.css | 2 + webui/src/lib/slash-command.ts | 51 ++++++++++ webui/src/tests/message-bubble.test.tsx | 99 ++++++++++++++++++- webui/src/tests/thread-composer.test.tsx | 36 +++++++ webui/src/tests/thread-shell.test.tsx | 63 ++++++++++++ 15 files changed, 459 insertions(+), 88 deletions(-) create mode 100644 webui/src/components/InlineTokenHighlight.tsx create mode 100644 webui/src/components/SlashCommandText.tsx create mode 100644 webui/src/lib/slash-command.ts diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index 8fdcbe7b..90c0e099 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -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 diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index d33a8f83..9a8d60be 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -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: diff --git a/webui/src/components/CliAppMentionText.tsx b/webui/src/components/CliAppMentionText.tsx index 84befeb4..d4b94397 100644 --- a/webui/src/components/CliAppMentionText.tsx +++ b/webui/src/components/CliAppMentionText.tsx @@ -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): string { const value = preset.display_name || preset.name; return ( @@ -36,7 +39,6 @@ export function mcpPresetInitials(preset: Pick 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 ( - {mentionName} - + ); } @@ -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 ( - {mentionName} - + ); } - -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)`; -} diff --git a/webui/src/components/InlineTokenHighlight.tsx b/webui/src/components/InlineTokenHighlight.tsx new file mode 100644 index 00000000..1985b070 --- /dev/null +++ b/webui/src/components/InlineTokenHighlight.tsx @@ -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 ( + + {children} + + ); +} + +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)`; +} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index b5ae6004..27daea09 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -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 ? ( + <> + + + + ) : ( + + ); return (
- + {messageText}

) : null} {hasText && showCopyAction ? ( diff --git a/webui/src/components/SlashCommandText.tsx b/webui/src/components/SlashCommandText.tsx new file mode 100644 index 00000000..c6c3fdf2 --- /dev/null +++ b/webui/src/components/SlashCommandText.tsx @@ -0,0 +1,22 @@ +import { + INLINE_TOKEN_HIGHLIGHT_COLOR, + InlineTokenHighlight, +} from "@/components/InlineTokenHighlight"; + +interface SlashCommandTextProps { + command: string; +} + +export function SlashCommandText({ + command, +}: SlashCommandTextProps) { + return ( + + {command} + + ); +} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 4b752fac..4fb0f4ab 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -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); diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 7536b848..29a68505 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -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 diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 0d38d9ff..3dd4fb63 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -238,7 +238,7 @@ function useInstalledSettingItems({ 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} diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 685bc066..cb7f9d6c 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -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 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" + ); +} diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index b0afa29a..c8a37b18 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -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(); + + 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( + , + ); + expect(screen.queryByTestId("message-slash-command")).not.toBeInTheDocument(); + expect(screen.getByText("/unknown value")).toBeInTheDocument(); + + rerender(); + 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( + , + ); + + 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 = { diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index c7e89a85..d3727a4b 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -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( + , + ); + + 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 }), diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index ea25653a..5f6c071e 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -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, + {}} + 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", + ); + }); });