fix(webui): decouple skill reference rendering

This commit is contained in:
chengyongru
2026-07-21 22:56:04 +08:00
committed by chengyongru
parent 79b89f4f4c
commit b32d673ead
7 changed files with 68 additions and 94 deletions
-5
View File
@@ -37,7 +37,6 @@ import type {
CliAppInfo, CliAppInfo,
McpPresetInfo, McpPresetInfo,
SlashCommand, SlashCommand,
SkillSummary,
UICliAppAttachment, UICliAppAttachment,
UIMcpPresetAttachment, UIMcpPresetAttachment,
UIImage, UIImage,
@@ -52,7 +51,6 @@ interface MessageBubbleProps {
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
skills?: SkillSummary[];
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
onForkFromHere?: () => void; onForkFromHere?: () => void;
} }
@@ -145,7 +143,6 @@ export function MessageBubble({
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
slashCommands = [], slashCommands = [],
skills = [],
onOpenFilePreview, onOpenFilePreview,
onForkFromHere, onForkFromHere,
}: MessageBubbleProps) { }: MessageBubbleProps) {
@@ -176,7 +173,6 @@ export function MessageBubble({
<SlashCommandText command={slashCommand.command} /> <SlashCommandText command={slashCommand.command} />
<UserMessageText <UserMessageText
text={message.content.slice(slashCommand.command.length)} text={message.content.slice(slashCommand.command.length)}
skills={skills}
cliApps={mentionCliApps} cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets} mcpPresets={mentionMcpPresets}
/> />
@@ -184,7 +180,6 @@ export function MessageBubble({
) : ( ) : (
<UserMessageText <UserMessageText
text={message.content} text={message.content}
skills={skills}
cliApps={mentionCliApps} cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets} mcpPresets={mentionMcpPresets}
/> />
+54 -36
View File
@@ -1,45 +1,40 @@
import { CliAppMentionText } from "@/components/CliAppMentionText"; import { Fragment } from "react";
import {
CliAppMentionToken,
McpPresetMentionToken,
splitCapabilityMentionSegments,
type CapabilityMentionSegment,
} from "@/components/CliAppMentionText";
import { import {
INLINE_TOKEN_HIGHLIGHT_COLOR, INLINE_TOKEN_HIGHLIGHT_COLOR,
InlineTokenHighlight, InlineTokenHighlight,
} from "@/components/InlineTokenHighlight"; } from "@/components/InlineTokenHighlight";
import type { CliAppInfo, McpPresetInfo, SkillSummary } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
type SkillReferenceSegment = type SkillReferenceSegment =
| { kind: "text"; text: string } | { kind: "text"; text: string }
| { kind: "skill"; text: string; skill: SkillSummary }; | { kind: "skill"; text: string; name: string };
function splitSkillReferenceSegments( type UserMessageSegment =
value: string, | CapabilityMentionSegment
skills: SkillSummary[], | { kind: "skill"; text: string; name: string };
): 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 }];
function splitSkillReferenceSegments(value: string): SkillReferenceSegment[] {
if (!value) return [];
const segments: SkillReferenceSegment[] = []; const segments: SkillReferenceSegment[] = [];
const referenceRe = /\$([A-Za-z0-9_-]+)/g; const referenceRe = /\$([A-Za-z0-9_-]+)/g;
let cursor = 0; let cursor = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = referenceRe.exec(value)) !== null) { while ((match = referenceRe.exec(value)) !== null) {
const name = match[1] ?? ""; const name = match[1] ?? "";
const skill = skillsByName.get(name.toLowerCase());
if (!skill) continue;
if (match.index > cursor) { if (match.index > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, match.index) }); segments.push({ kind: "text", text: value.slice(cursor, match.index) });
} }
segments.push({ segments.push({
kind: "skill", kind: "skill",
text: value.slice(match.index, referenceRe.lastIndex), text: value.slice(match.index, referenceRe.lastIndex),
skill, name,
}); });
cursor = referenceRe.lastIndex; cursor = referenceRe.lastIndex;
} }
@@ -49,42 +44,65 @@ function splitSkillReferenceSegments(
return segments.length ? segments : [{ kind: "text", text: value }]; 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({ export function UserMessageText({
text, text,
skills,
cliApps, cliApps,
mcpPresets, mcpPresets,
}: { }: {
text: string; text: string;
skills: SkillSummary[];
cliApps: CliAppInfo[]; cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[]; mcpPresets: McpPresetInfo[];
}) { }) {
const segments = splitSkillReferenceSegments(text, skills); const segments = splitUserMessageSegments(text, cliApps, mcpPresets);
return ( return (
<> <>
{segments.map((segment, index) => { {segments.map((segment, index) => {
if (segment.kind === "text") { if (segment.kind === "text") {
return ( return <Fragment key={`text-${index}`}>{segment.text}</Fragment>;
<CliAppMentionText
key={`text-${index}`}
text={segment.text}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
);
} }
return ( if (segment.kind === "skill") return (
<InlineTokenHighlight <InlineTokenHighlight
key={`skill-${segment.skill.name}-${index}`} key={`skill-${segment.name}-${index}`}
testId={`message-skill-reference-${segment.skill.name}`} testId={`message-skill-reference-${segment.name.toLowerCase()}`}
title={`Skill: ${segment.skill.name}`} title={`Skill: ${segment.name}`}
color={INLINE_TOKEN_HIGHLIGHT_COLOR} color={INLINE_TOKEN_HIGHLIGHT_COLOR}
className="font-medium" className="font-medium"
> >
{segment.text} {segment.text}
</InlineTokenHighlight> </InlineTokenHighlight>
); );
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="message"
/>
);
})} })}
</> </>
); );
+1 -10
View File
@@ -3,13 +3,7 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline"; import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import type { import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
CliAppInfo,
McpPresetInfo,
SlashCommand,
SkillSummary,
UIMessage,
} from "@/lib/types";
interface ThreadMessagesProps { interface ThreadMessagesProps {
messages: UIMessage[]; messages: UIMessage[];
@@ -19,7 +13,6 @@ interface ThreadMessagesProps {
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
skills?: SkillSummary[];
forkBoundaryMessageCount?: number | null; forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void; onForkFromMessage?: (beforeUserIndex: number) => void;
@@ -60,7 +53,6 @@ export function ThreadMessages({
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
slashCommands = [], slashCommands = [],
skills = [],
forkBoundaryMessageCount = null, forkBoundaryMessageCount = null,
onOpenFilePreview, onOpenFilePreview,
onForkFromMessage, onForkFromMessage,
@@ -123,7 +115,6 @@ export function ThreadMessages({
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
skills={skills}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
onForkFromHere={ onForkFromHere={
onForkFromMessage && forkIndex !== undefined onForkFromMessage && forkIndex !== undefined
@@ -897,7 +897,6 @@ export function ThreadShell({
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
skills={skills}
forkBoundaryMessageCount={forkBoundaryMessageCount} forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore} hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
+1 -10
View File
@@ -22,13 +22,7 @@ import {
promptTop, promptTop,
} from "@/components/thread/promptNavigation"; } from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
CliAppInfo,
McpPresetInfo,
SlashCommand,
SkillSummary,
UIMessage,
} from "@/lib/types";
export interface ThreadViewportHandle { export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void; jumpToUserPrompt: (promptId: string) => void;
@@ -47,7 +41,6 @@ interface ThreadViewportProps {
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
skills?: SkillSummary[];
forkBoundaryMessageCount?: number | null; forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean; hasMoreBefore?: boolean;
loadingOlder?: boolean; loadingOlder?: boolean;
@@ -120,7 +113,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
slashCommands = [], slashCommands = [],
skills = [],
forkBoundaryMessageCount = null, forkBoundaryMessageCount = null,
hasMoreBefore = false, hasMoreBefore = false,
loadingOlder = false, loadingOlder = false,
@@ -537,7 +529,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
skills={skills}
forkBoundaryMessageCount={visibleForkBoundaryMessageCount} forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage} onForkFromMessage={onForkFromMessage}
+11 -25
View File
@@ -6,7 +6,6 @@ import type {
CliAppInfo, CliAppInfo,
McpPresetInfo, McpPresetInfo,
SlashCommand, SlashCommand,
SkillSummary,
UIMessage, UIMessage,
} from "@/lib/types"; } from "@/lib/types";
@@ -94,21 +93,6 @@ const SLASH_COMMANDS: SlashCommand[] = [
}, },
]; ];
const SKILLS: SkillSummary[] = [
{
name: "github",
description: "Work with pull requests and issues",
source: "builtin",
available: true,
},
{
name: "blocked-skill",
description: "Needs an unavailable dependency",
source: "workspace",
available: false,
},
];
describe("MessageBubble", () => { describe("MessageBubble", () => {
it("renders user messages as right-aligned pills", () => { it("renders user messages as right-aligned pills", () => {
const message: UIMessage = { const message: UIMessage = {
@@ -225,7 +209,7 @@ describe("MessageBubble", () => {
expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom"); 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 = { const message: UIMessage = {
id: "u-skill-reference", id: "u-skill-reference",
role: "user", role: "user",
@@ -236,7 +220,6 @@ describe("MessageBubble", () => {
render( render(
<MessageBubble <MessageBubble
message={message} message={message}
skills={SKILLS}
cliApps={CLI_APPS} cliApps={CLI_APPS}
/>, />,
); );
@@ -254,20 +237,23 @@ describe("MessageBubble", () => {
expect(skill.parentElement).toHaveTextContent("Ask $github to review this with @zoom"); 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 = { const message: UIMessage = {
id: "u-plain-skill-reference", id: "u-plain-skill-reference",
role: "user", role: "user",
content: "Try $unknown or $blocked-skill", content: "Try $unknown or $blocked-skill and $",
createdAt: Date.now(), createdAt: Date.now(),
}; };
render(<MessageBubble message={message} skills={SKILLS} />); render(<MessageBubble message={message} />);
expect(screen.queryByTestId("message-skill-reference-unknown")).not.toBeInTheDocument(); expect(screen.getByTestId("message-skill-reference-unknown")).toHaveTextContent("$unknown");
expect(screen.queryByTestId("message-skill-reference-blocked-skill")) expect(screen.getByTestId("message-skill-reference-blocked-skill"))
.not.toBeInTheDocument(); .toHaveTextContent("$blocked-skill");
expect(screen.getByText("Try $unknown or $blocked-skill")).toBeInTheDocument(); 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", () => { it("renders fork control in completed assistant action rows", () => {
+1 -7
View File
@@ -484,7 +484,7 @@ describe("ThreadShell", () => {
expect(screen.getByText("persist me across tabs")).toBeInTheDocument(); 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(); const client = makeClient();
render(wrap( render(wrap(
client, client,
@@ -492,12 +492,6 @@ describe("ThreadShell", () => {
session={session("skill-reference")} session={session("skill-reference")}
title="Skill reference" title="Skill reference"
onToggleSidebar={() => {}} onToggleSidebar={() => {}}
skills={[{
name: "github",
description: "Work with pull requests and issues",
source: "builtin",
available: true,
}]}
/>, />,
)); ));