feat(webui): highlight slash commands and app mentions (#4933)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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