From b32d673eadb83cd4cf64b9fb3518a55586bf5725 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 21 Jul 2026 18:42:25 +0800 Subject: [PATCH] fix(webui): decouple skill reference rendering --- webui/src/components/MessageBubble.tsx | 5 -- webui/src/components/UserMessageText.tsx | 90 +++++++++++-------- .../src/components/thread/ThreadMessages.tsx | 11 +-- webui/src/components/thread/ThreadShell.tsx | 1 - .../src/components/thread/ThreadViewport.tsx | 11 +-- webui/src/tests/message-bubble.test.tsx | 36 +++----- webui/src/tests/thread-shell.test.tsx | 8 +- 7 files changed, 68 insertions(+), 94 deletions(-) diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 7a1c3536..d73b7d61 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -37,7 +37,6 @@ import type { CliAppInfo, McpPresetInfo, SlashCommand, - SkillSummary, UICliAppAttachment, UIMcpPresetAttachment, UIImage, @@ -52,7 +51,6 @@ interface MessageBubbleProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; - skills?: SkillSummary[]; onOpenFilePreview?: (path: string) => void; onForkFromHere?: () => void; } @@ -145,7 +143,6 @@ export function MessageBubble({ cliApps = [], mcpPresets = [], slashCommands = [], - skills = [], onOpenFilePreview, onForkFromHere, }: MessageBubbleProps) { @@ -176,7 +173,6 @@ export function MessageBubble({ @@ -184,7 +180,6 @@ export function MessageBubble({ ) : ( diff --git a/webui/src/components/UserMessageText.tsx b/webui/src/components/UserMessageText.tsx index 3a7d38d5..d0b054c1 100644 --- a/webui/src/components/UserMessageText.tsx +++ b/webui/src/components/UserMessageText.tsx @@ -1,45 +1,40 @@ -import { CliAppMentionText } from "@/components/CliAppMentionText"; +import { Fragment } from "react"; + +import { + CliAppMentionToken, + McpPresetMentionToken, + splitCapabilityMentionSegments, + type CapabilityMentionSegment, +} from "@/components/CliAppMentionText"; import { INLINE_TOKEN_HIGHLIGHT_COLOR, InlineTokenHighlight, } from "@/components/InlineTokenHighlight"; -import type { CliAppInfo, McpPresetInfo, SkillSummary } from "@/lib/types"; +import type { CliAppInfo, McpPresetInfo } from "@/lib/types"; type SkillReferenceSegment = | { kind: "text"; text: string } - | { kind: "skill"; text: string; skill: SkillSummary }; + | { kind: "skill"; text: string; name: string }; -function splitSkillReferenceSegments( - value: string, - skills: SkillSummary[], -): SkillReferenceSegment[] { - if (!value || skills.length === 0) { - return value ? [{ kind: "text", text: value }] : []; - } - - const skillsByName = new Map( - skills - .filter((skill) => skill.available) - .map((skill) => [skill.name.toLowerCase(), skill]), - ); - if (skillsByName.size === 0) return [{ kind: "text", text: value }]; +type UserMessageSegment = + | CapabilityMentionSegment + | { kind: "skill"; text: string; name: string }; +function splitSkillReferenceSegments(value: string): SkillReferenceSegment[] { + if (!value) return []; const segments: SkillReferenceSegment[] = []; const referenceRe = /\$([A-Za-z0-9_-]+)/g; let cursor = 0; let match: RegExpExecArray | null; while ((match = referenceRe.exec(value)) !== null) { const name = match[1] ?? ""; - const skill = skillsByName.get(name.toLowerCase()); - if (!skill) continue; - if (match.index > cursor) { segments.push({ kind: "text", text: value.slice(cursor, match.index) }); } segments.push({ kind: "skill", text: value.slice(match.index, referenceRe.lastIndex), - skill, + name, }); cursor = referenceRe.lastIndex; } @@ -49,42 +44,65 @@ function splitSkillReferenceSegments( return segments.length ? segments : [{ kind: "text", text: value }]; } +function splitUserMessageSegments( + value: string, + cliApps: CliAppInfo[], + mcpPresets: McpPresetInfo[], +): UserMessageSegment[] { + const segments: UserMessageSegment[] = []; + for (const segment of splitCapabilityMentionSegments(value, cliApps, mcpPresets)) { + if (segment.kind === "text") { + segments.push(...splitSkillReferenceSegments(segment.text)); + } else { + segments.push(segment); + } + } + return segments; +} + export function UserMessageText({ text, - skills, cliApps, mcpPresets, }: { text: string; - skills: SkillSummary[]; cliApps: CliAppInfo[]; mcpPresets: McpPresetInfo[]; }) { - const segments = splitSkillReferenceSegments(text, skills); + const segments = splitUserMessageSegments(text, cliApps, mcpPresets); return ( <> {segments.map((segment, index) => { if (segment.kind === "text") { - return ( - - ); + return {segment.text}; } - return ( + if (segment.kind === "skill") return ( {segment.text} ); + if (segment.kind === "cli") return ( + + ); + return ( + + ); })} ); diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 7eaffc9c..9271fd74 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -3,13 +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, - SlashCommand, - SkillSummary, - UIMessage, -} from "@/lib/types"; +import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types"; interface ThreadMessagesProps { messages: UIMessage[]; @@ -19,7 +13,6 @@ interface ThreadMessagesProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; - skills?: SkillSummary[]; forkBoundaryMessageCount?: number | null; onOpenFilePreview?: (path: string) => void; onForkFromMessage?: (beforeUserIndex: number) => void; @@ -60,7 +53,6 @@ export function ThreadMessages({ cliApps = [], mcpPresets = [], slashCommands = [], - skills = [], forkBoundaryMessageCount = null, onOpenFilePreview, onForkFromMessage, @@ -123,7 +115,6 @@ export function ThreadMessages({ cliApps={cliApps} mcpPresets={mcpPresets} slashCommands={slashCommands} - skills={skills} onOpenFilePreview={onOpenFilePreview} onForkFromHere={ onForkFromMessage && forkIndex !== undefined diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 0d0d7505..9a52c845 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -897,7 +897,6 @@ export function ThreadShell({ cliApps={cliApps} mcpPresets={mcpPresets} slashCommands={slashCommands} - skills={skills} forkBoundaryMessageCount={forkBoundaryMessageCount} hasMoreBefore={hasMoreBefore} loadingOlder={loadingOlder} diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 972e9556..cb7f9d6c 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -22,13 +22,7 @@ import { promptTop, } from "@/components/thread/promptNavigation"; import { cn } from "@/lib/utils"; -import type { - CliAppInfo, - McpPresetInfo, - SlashCommand, - SkillSummary, - UIMessage, -} from "@/lib/types"; +import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types"; export interface ThreadViewportHandle { jumpToUserPrompt: (promptId: string) => void; @@ -47,7 +41,6 @@ interface ThreadViewportProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; - skills?: SkillSummary[]; forkBoundaryMessageCount?: number | null; hasMoreBefore?: boolean; loadingOlder?: boolean; @@ -120,7 +113,6 @@ export const ThreadViewport = forwardRef { it("renders user messages as right-aligned pills", () => { const message: UIMessage = { @@ -225,7 +209,7 @@ describe("MessageBubble", () => { expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom"); }); - it("highlights available skill references while preserving other message tokens", () => { + it("highlights skill references without a live skill catalog", () => { const message: UIMessage = { id: "u-skill-reference", role: "user", @@ -236,7 +220,6 @@ describe("MessageBubble", () => { render( , ); @@ -254,20 +237,23 @@ describe("MessageBubble", () => { expect(skill.parentElement).toHaveTextContent("Ask $github to review this with @zoom"); }); - it("keeps unknown and unavailable skill references as plain message text", () => { + it("highlights well-formed skill references and leaves a bare marker plain", () => { const message: UIMessage = { id: "u-plain-skill-reference", role: "user", - content: "Try $unknown or $blocked-skill", + content: "Try $unknown or $blocked-skill and $", createdAt: Date.now(), }; - render(); + render(); - expect(screen.queryByTestId("message-skill-reference-unknown")).not.toBeInTheDocument(); - expect(screen.queryByTestId("message-skill-reference-blocked-skill")) - .not.toBeInTheDocument(); - expect(screen.getByText("Try $unknown or $blocked-skill")).toBeInTheDocument(); + expect(screen.getByTestId("message-skill-reference-unknown")).toHaveTextContent("$unknown"); + expect(screen.getByTestId("message-skill-reference-blocked-skill")) + .toHaveTextContent("$blocked-skill"); + const references = screen.getAllByTestId(/^message-skill-reference-/); + expect(references).toHaveLength(2); + expect(references[0].parentElement) + .toHaveTextContent("Try $unknown or $blocked-skill and $"); }); it("renders fork control in completed assistant action rows", () => { diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index cc2d56c6..e8bfd573 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -484,7 +484,7 @@ describe("ThreadShell", () => { expect(screen.getByText("persist me across tabs")).toBeInTheDocument(); }); - it("passes skill metadata to sent user messages", async () => { + it("highlights sent skill references without skill metadata", async () => { const client = makeClient(); render(wrap( client, @@ -492,12 +492,6 @@ describe("ThreadShell", () => { session={session("skill-reference")} title="Skill reference" onToggleSidebar={() => {}} - skills={[{ - name: "github", - description: "Work with pull requests and issues", - source: "builtin", - available: true, - }]} />, ));