diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 27daea09..7a1c3536 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -18,10 +18,10 @@ import { import { useTranslation } from "react-i18next"; 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 { UserMessageText } from "@/components/UserMessageText"; import { Tooltip, TooltipContent, @@ -37,6 +37,7 @@ import type { CliAppInfo, McpPresetInfo, SlashCommand, + SkillSummary, UICliAppAttachment, UIMcpPresetAttachment, UIImage, @@ -51,6 +52,7 @@ interface MessageBubbleProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; + skills?: SkillSummary[]; onOpenFilePreview?: (path: string) => void; onForkFromHere?: () => void; } @@ -143,6 +145,7 @@ export function MessageBubble({ cliApps = [], mcpPresets = [], slashCommands = [], + skills = [], onOpenFilePreview, onForkFromHere, }: MessageBubbleProps) { @@ -171,15 +174,17 @@ export function MessageBubble({ const messageText = slashCommand ? ( <> - ) : ( - diff --git a/webui/src/components/UserMessageText.tsx b/webui/src/components/UserMessageText.tsx new file mode 100644 index 00000000..3a7d38d5 --- /dev/null +++ b/webui/src/components/UserMessageText.tsx @@ -0,0 +1,91 @@ +import { CliAppMentionText } from "@/components/CliAppMentionText"; +import { + INLINE_TOKEN_HIGHLIGHT_COLOR, + InlineTokenHighlight, +} from "@/components/InlineTokenHighlight"; +import type { CliAppInfo, McpPresetInfo, SkillSummary } from "@/lib/types"; + +type SkillReferenceSegment = + | { kind: "text"; text: string } + | { kind: "skill"; text: string; skill: SkillSummary }; + +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 }]; + + 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, + }); + cursor = referenceRe.lastIndex; + } + if (cursor < value.length) { + segments.push({ kind: "text", text: value.slice(cursor) }); + } + return segments.length ? segments : [{ kind: "text", text: value }]; +} + +export function UserMessageText({ + text, + skills, + cliApps, + mcpPresets, +}: { + text: string; + skills: SkillSummary[]; + cliApps: CliAppInfo[]; + mcpPresets: McpPresetInfo[]; +}) { + const segments = splitSkillReferenceSegments(text, skills); + return ( + <> + {segments.map((segment, index) => { + if (segment.kind === "text") { + return ( + + ); + } + return ( + + {segment.text} + + ); + })} + + ); +} diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 9271fd74..7eaffc9c 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -3,7 +3,13 @@ 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, UIMessage } from "@/lib/types"; +import type { + CliAppInfo, + McpPresetInfo, + SlashCommand, + SkillSummary, + UIMessage, +} from "@/lib/types"; interface ThreadMessagesProps { messages: UIMessage[]; @@ -13,6 +19,7 @@ interface ThreadMessagesProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; + skills?: SkillSummary[]; forkBoundaryMessageCount?: number | null; onOpenFilePreview?: (path: string) => void; onForkFromMessage?: (beforeUserIndex: number) => void; @@ -53,6 +60,7 @@ export function ThreadMessages({ cliApps = [], mcpPresets = [], slashCommands = [], + skills = [], forkBoundaryMessageCount = null, onOpenFilePreview, onForkFromMessage, @@ -115,6 +123,7 @@ 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 9a52c845..0d0d7505 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -897,6 +897,7 @@ 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 cb7f9d6c..972e9556 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -22,7 +22,13 @@ import { promptTop, } from "@/components/thread/promptNavigation"; import { cn } from "@/lib/utils"; -import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types"; +import type { + CliAppInfo, + McpPresetInfo, + SlashCommand, + SkillSummary, + UIMessage, +} from "@/lib/types"; export interface ThreadViewportHandle { jumpToUserPrompt: (promptId: string) => void; @@ -41,6 +47,7 @@ interface ThreadViewportProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; + skills?: SkillSummary[]; forkBoundaryMessageCount?: number | null; hasMoreBefore?: boolean; loadingOlder?: boolean; @@ -113,6 +120,7 @@ export const ThreadViewport = forwardRef { it("renders user messages as right-aligned pills", () => { const message: UIMessage = { @@ -204,6 +225,51 @@ describe("MessageBubble", () => { expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom"); }); + it("highlights available skill references while preserving other message tokens", () => { + const message: UIMessage = { + id: "u-skill-reference", + role: "user", + content: "Ask $github to review this with @zoom", + createdAt: Date.now(), + }; + + render( + , + ); + + const skill = screen.getByTestId("message-skill-reference-github"); + expect(skill).toHaveTextContent("$github"); + expect(skill).toHaveClass( + "font-medium", + "transition-[color,text-shadow]", + "duration-150", + ); + expect(skill.getAttribute("style")).toContain("var(--inline-token-highlight)"); + expect(skill.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/); + expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom"); + expect(skill.parentElement).toHaveTextContent("Ask $github to review this with @zoom"); + }); + + it("keeps unknown and unavailable skill references as plain message text", () => { + const message: UIMessage = { + id: "u-plain-skill-reference", + role: "user", + content: "Try $unknown or $blocked-skill", + createdAt: Date.now(), + }; + + 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(); + }); + it("renders fork control in completed assistant action rows", () => { const onForkFromHere = vi.fn(); const message: UIMessage = { diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 8d4fb374..cc2d56c6 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -484,6 +484,35 @@ describe("ThreadShell", () => { expect(screen.getByText("persist me across tabs")).toBeInTheDocument(); }); + it("passes skill metadata to sent user messages", async () => { + const client = makeClient(); + render(wrap( + client, + {}} + skills={[{ + name: "github", + description: "Work with pull requests and issues", + source: "builtin", + available: true, + }]} + />, + )); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "Use $github for this" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => + expectSendMessageWithTurn(client, "skill-reference", "Use $github for this"), + ); + expect(screen.getByTestId("message-skill-reference-github")) + .toHaveTextContent("$github"); + }); + it("clears the old thread when the active session is removed", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a");