Skip to content

Commit 11f4055

Browse files
authored
feat(web): render skill calls as inline chips (#2572)
1 parent 1bcfc88 commit 11f4055

6 files changed

Lines changed: 134 additions & 8 deletions

File tree

apps/web/src/components/ChatMarkdown.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
22
import { CheckIcon, CopyIcon } from "lucide-react";
3+
import type { ServerProviderSkill } from "@t3tools/contracts";
34
import React, {
45
Children,
56
Suspense,
@@ -19,6 +20,7 @@ import ReactMarkdown from "react-markdown";
1920
import { defaultUrlTransform } from "react-markdown";
2021
import remarkGfm from "remark-gfm";
2122
import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
23+
import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
2224
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
2325
import { stackedThreadToast, toastManager } from "./ui/toast";
2426
import { openInPreferredEditor } from "../editorPreferences";
@@ -59,8 +61,11 @@ interface ChatMarkdownProps {
5961
text: string;
6062
cwd: string | undefined;
6163
isStreaming?: boolean;
64+
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
6265
}
6366

67+
const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
68+
6469
const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
6570
const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
6671
const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
@@ -507,7 +512,12 @@ function areMarkdownFileLinkPropsEqual(
507512
);
508513
}
509514

510-
function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
515+
function ChatMarkdown({
516+
text,
517+
cwd,
518+
isStreaming = false,
519+
skills = EMPTY_MARKDOWN_SKILLS,
520+
}: ChatMarkdownProps) {
511521
const { resolvedTheme } = useTheme();
512522
const diffThemeName = resolveDiffThemeName(resolvedTheme);
513523
const markdownFileLinkMetaByHref = useMemo(() => {
@@ -534,6 +544,12 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
534544
}, []);
535545
const markdownComponents = useMemo<Components>(
536546
() => ({
547+
p({ node: _node, children, ...props }) {
548+
return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
549+
},
550+
li({ node: _node, children, ...props }) {
551+
return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>;
552+
},
537553
a({ node: _node, href, ...props }) {
538554
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
539555
const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
@@ -592,6 +608,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
592608
isStreaming,
593609
markdownFileLinkMetaByHref,
594610
resolvedTheme,
611+
skills,
595612
],
596613
);
597614

apps/web/src/components/ChatView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
195195
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
196196
const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
197197
const EMPTY_PROVIDERS: ServerProvider[] = [];
198+
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
198199
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
199200
type EnvironmentUnavailableState = {
200201
readonly environmentId: EnvironmentId;
@@ -3574,6 +3575,7 @@ export default function ChatView(props: ChatViewProps) {
35743575
resolvedTheme={resolvedTheme}
35753576
timestampFormat={timestampFormat}
35763577
workspaceRoot={activeWorkspaceRoot}
3578+
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
35773579
onIsAtEndChange={onIsAtEndChange}
35783580
/>
35793581

apps/web/src/components/ComposerPromptEditor.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7373
COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
7474
COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
75+
SKILL_CHIP_ICON_SVG,
7576
} from "./composerInlineChip";
7677
import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
7778
import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
@@ -216,8 +217,6 @@ function $createComposerMentionNode(path: string): ComposerMentionNode {
216217
return $applyNodeReplacement(new ComposerMentionNode(path));
217218
}
218219

219-
const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
220-
221220
function resolveSkillDescription(
222221
skill: Pick<ServerProviderSkill, "shortDescription" | "description">,
223222
): string | null {

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";
1+
import {
2+
type EnvironmentId,
3+
type MessageId,
4+
type ServerProviderSkill,
5+
type TurnId,
6+
} from "@t3tools/contracts";
27
import {
38
createContext,
49
memo,
@@ -60,6 +65,7 @@ import {
6065
formatInlineTerminalContextLabel,
6166
textContainsInlineTerminalContextLabels,
6267
} from "./userMessageTerminalContexts";
68+
import { SkillInlineText } from "./SkillInlineText";
6369
import { formatWorkspaceRelativePath } from "../../filePathDisplay";
6470

6571
// ---------------------------------------------------------------------------
@@ -75,6 +81,7 @@ interface TimelineRowSharedState {
7581
markdownCwd: string | undefined;
7682
resolvedTheme: "light" | "dark";
7783
workspaceRoot: string | undefined;
84+
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
7885
activeThreadEnvironmentId: EnvironmentId;
7986
onRevertUserMessage: (messageId: MessageId) => void;
8087
onImageExpand: (preview: ExpandedImagePreview) => void;
@@ -93,6 +100,7 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
93100
const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
94101
const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
95102
const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
103+
const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
96104

97105
// ---------------------------------------------------------------------------
98106
// Props (public API)
@@ -119,6 +127,7 @@ interface MessagesTimelineProps {
119127
resolvedTheme: "light" | "dark";
120128
timestampFormat: TimestampFormat;
121129
workspaceRoot: string | undefined;
130+
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
122131
onIsAtEndChange: (isAtEnd: boolean) => void;
123132
}
124133

@@ -147,6 +156,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
147156
resolvedTheme,
148157
timestampFormat,
149158
workspaceRoot,
159+
skills = EMPTY_TIMELINE_SKILLS,
150160
onIsAtEndChange,
151161
}: MessagesTimelineProps) {
152162
const rawRows = useMemo(
@@ -202,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
202212
markdownCwd,
203213
resolvedTheme,
204214
workspaceRoot,
215+
skills,
205216
activeThreadEnvironmentId,
206217
onRevertUserMessage,
207218
onImageExpand,
@@ -213,6 +224,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
213224
markdownCwd,
214225
resolvedTheme,
215226
workspaceRoot,
227+
skills,
216228
activeThreadEnvironmentId,
217229
onRevertUserMessage,
218230
onImageExpand,
@@ -357,6 +369,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
357369
<UserMessageBody
358370
text={displayedUserMessage.visibleText}
359371
terminalContexts={terminalContexts}
372+
skills={ctx.skills}
360373
/>
361374
)}
362375
<div className="mt-1.5 flex items-center justify-end gap-2">
@@ -405,6 +418,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
405418
text={messageText}
406419
cwd={ctx.markdownCwd}
407420
isStreaming={Boolean(row.message.streaming)}
421+
skills={ctx.skills}
408422
/>
409423
<AssistantChangedFilesSection
410424
turnSummary={row.assistantTurnDiffSummary}
@@ -744,6 +758,7 @@ const UserMessageTerminalContextInlineLabel = memo(
744758
const UserMessageBody = memo(function UserMessageBody(props: {
745759
text: string;
746760
terminalContexts: ParsedTerminalContextEntry[];
761+
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
747762
}) {
748763
if (props.terminalContexts.length > 0) {
749764
const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels(
@@ -766,7 +781,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
766781
if (matchIndex > cursor) {
767782
inlineNodes.push(
768783
<span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}>
769-
{props.text.slice(cursor, matchIndex)}
784+
<SkillInlineText text={props.text.slice(cursor, matchIndex)} skills={props.skills} />
770785
</span>,
771786
);
772787
}
@@ -783,7 +798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
783798
if (cursor < props.text.length) {
784799
inlineNodes.push(
785800
<span key={`user-message-terminal-context-inline-rest:${cursor}`}>
786-
{props.text.slice(cursor)}
801+
<SkillInlineText text={props.text.slice(cursor)} skills={props.skills} />
787802
</span>,
788803
);
789804
}
@@ -811,7 +826,11 @@ const UserMessageBody = memo(function UserMessageBody(props: {
811826
}
812827

813828
if (props.text.length > 0) {
814-
inlineNodes.push(<span key="user-message-terminal-context-inline-text">{props.text}</span>);
829+
inlineNodes.push(
830+
<span key="user-message-terminal-context-inline-text">
831+
<SkillInlineText text={props.text} skills={props.skills} />
832+
</span>,
833+
);
815834
} else if (inlinePrefix.length === 0) {
816835
return null;
817836
}
@@ -829,7 +848,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
829848

830849
return (
831850
<div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground">
832-
{props.text}
851+
<SkillInlineText text={props.text} skills={props.skills} />
833852
</div>
834853
);
835854
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
2+
import type { ServerProviderSkill } from "@t3tools/contracts";
3+
4+
import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
5+
import {
6+
COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7+
COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
8+
COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
9+
SKILL_CHIP_ICON_SVG,
10+
} from "../composerInlineChip";
11+
12+
const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
13+
14+
type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
15+
16+
export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) {
17+
const nodes: ReactNode[] = [];
18+
let cursor = 0;
19+
20+
for (const match of props.text.matchAll(SKILL_TOKEN_REGEX)) {
21+
const prefix = match[1] ?? "";
22+
const name = match[2] ?? "";
23+
const start = (match.index ?? 0) + prefix.length;
24+
const rawText = `$${name}`;
25+
const skill = props.skills.find((candidate) => candidate.name === name);
26+
if (!skill) {
27+
continue;
28+
}
29+
30+
if (start > cursor) {
31+
nodes.push(props.text.slice(cursor, start));
32+
}
33+
nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />);
34+
cursor = start + rawText.length;
35+
}
36+
37+
if (cursor === 0) {
38+
return <>{props.text}</>;
39+
}
40+
if (cursor < props.text.length) {
41+
nodes.push(props.text.slice(cursor));
42+
}
43+
return <>{nodes}</>;
44+
}
45+
46+
export function renderSkillInlineMarkdownChildren(
47+
children: ReactNode,
48+
skills: ReadonlyArray<InlineSkill>,
49+
): ReactNode {
50+
return Children.map(children, (child) => {
51+
if (typeof child === "string") {
52+
return <SkillInlineText text={child} skills={skills} />;
53+
}
54+
if (!isValidElement<{ children?: ReactNode }>(child)) {
55+
return child;
56+
}
57+
if (child.type === "code" || child.type === "a") {
58+
return child;
59+
}
60+
if (!("children" in child.props)) {
61+
return child;
62+
}
63+
return cloneElement(
64+
child,
65+
undefined,
66+
renderSkillInlineMarkdownChildren(child.props.children, skills),
67+
);
68+
});
69+
}
70+
71+
function SkillChip(props: { skill: InlineSkill; rawText: string }) {
72+
return (
73+
<span className="inline-flex align-middle leading-none">
74+
<span className="sr-only">{props.rawText}</span>
75+
<span aria-hidden="true" className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}>
76+
<span
77+
aria-hidden="true"
78+
className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
79+
dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
80+
/>
81+
<span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>
82+
{formatProviderSkillDisplayName(props.skill)}
83+
</span>
84+
</span>
85+
</span>
86+
);
87+
}

apps/web/src/components/composerInlineChip.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@ export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leadi
88
export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
99
"inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300";
1010

11+
export const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
12+
1113
export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =
1214
"ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";

0 commit comments

Comments
 (0)