feat(webui): highlight skill references in sent messages
This commit is contained in:
@@ -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 ? (
|
||||
<>
|
||||
<SlashCommandText command={slashCommand.command} />
|
||||
<CliAppMentionText
|
||||
<UserMessageText
|
||||
text={message.content.slice(slashCommand.command.length)}
|
||||
skills={skills}
|
||||
cliApps={mentionCliApps}
|
||||
mcpPresets={mentionMcpPresets}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CliAppMentionText
|
||||
<UserMessageText
|
||||
text={message.content}
|
||||
skills={skills}
|
||||
cliApps={mentionCliApps}
|
||||
mcpPresets={mentionMcpPresets}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<CliAppMentionText
|
||||
key={`text-${index}`}
|
||||
text={segment.text}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<InlineTokenHighlight
|
||||
key={`skill-${segment.skill.name}-${index}`}
|
||||
testId={`message-skill-reference-${segment.skill.name}`}
|
||||
title={`Skill: ${segment.skill.name}`}
|
||||
color={INLINE_TOKEN_HIGHLIGHT_COLOR}
|
||||
className="font-medium"
|
||||
>
|
||||
{segment.text}
|
||||
</InlineTokenHighlight>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -897,6 +897,7 @@ export function ThreadShell({
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
skills={skills}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
|
||||
@@ -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<ThreadViewportHandle, ThreadViewportPro
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
slashCommands = [],
|
||||
skills = [],
|
||||
forkBoundaryMessageCount = null,
|
||||
hasMoreBefore = false,
|
||||
loadingOlder = false,
|
||||
@@ -529,6 +537,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
skills={skills}
|
||||
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
|
||||
@@ -2,7 +2,13 @@ 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, SlashCommand, UIMessage } from "@/lib/types";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
SlashCommand,
|
||||
SkillSummary,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
const CLI_APPS: CliAppInfo[] = [
|
||||
{
|
||||
@@ -88,6 +94,21 @@ 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", () => {
|
||||
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(
|
||||
<MessageBubble
|
||||
message={message}
|
||||
skills={SKILLS}
|
||||
cliApps={CLI_APPS}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<MessageBubble message={message} skills={SKILLS} />);
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
<ThreadShell
|
||||
session={session("skill-reference")}
|
||||
title="Skill reference"
|
||||
onToggleSidebar={() => {}}
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user