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,
- }]}
/>,
));