fix(webui): decouple skill reference rendering
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}]}
|
|
||||||
/>,
|
/>,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user