From e5fe707794bea30d76f1c8e0f27dd7cdd7ddbfc9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 7 May 2026 00:41:17 -0400 Subject: [PATCH] feat(web): render skill calls as inline chips --- apps/web/src/components/ChatMarkdown.tsx | 19 +++- apps/web/src/components/ChatView.tsx | 2 + .../src/components/ComposerPromptEditor.tsx | 3 +- .../src/components/chat/MessagesTimeline.tsx | 29 +++++-- .../src/components/chat/SkillInlineText.tsx | 87 +++++++++++++++++++ apps/web/src/components/composerInlineChip.ts | 2 + 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/chat/SkillInlineText.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index acbaba99fa8..6a85ccee96a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,5 +1,6 @@ import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, CopyIcon } from "lucide-react"; +import type { ServerProviderSkill } from "@t3tools/contracts"; import React, { Children, Suspense, @@ -19,6 +20,7 @@ import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; +import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; @@ -59,8 +61,11 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; isStreaming?: boolean; + skills?: ReadonlyArray>; } +const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; + const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; @@ -507,7 +512,12 @@ function areMarkdownFileLinkPropsEqual( ); } -function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { +function ChatMarkdown({ + text, + cwd, + isStreaming = false, + skills = EMPTY_MARKDOWN_SKILLS, +}: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { @@ -534,6 +544,12 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { }, []); const markdownComponents = useMemo( () => ({ + p({ node: _node, children, ...props }) { + return

{renderSkillInlineMarkdownChildren(children, skills)}

; + }, + li({ node: _node, children, ...props }) { + return
  • {renderSkillInlineMarkdownChildren(children, skills)}
  • ; + }, a({ node: _node, href, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; @@ -592,6 +608,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { isStreaming, markdownFileLinkMetaByHref, resolvedTheme, + skills, ], ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ef221e262a..6b84aa11ca6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -195,6 +195,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; @@ -3574,6 +3575,7 @@ export default function ChatView(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} + skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} onIsAtEndChange={onIsAtEndChange} /> diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index c9696b0c737..453be25a93f 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -72,6 +72,7 @@ import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; @@ -216,8 +217,6 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path)); } -const SKILL_CHIP_ICON_SVG = ``; - function resolveSkillDescription( skill: Pick, ): string | null { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dafa8161bd2..a4b0f4c992b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,9 @@ -import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; +import { + type EnvironmentId, + type MessageId, + type ServerProviderSkill, + type TurnId, +} from "@t3tools/contracts"; import { createContext, memo, @@ -60,6 +65,7 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; // --------------------------------------------------------------------------- @@ -75,6 +81,7 @@ interface TimelineRowSharedState { markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; + skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; @@ -93,6 +100,7 @@ const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
    ; const TIMELINE_LIST_FOOTER =
    ; +const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; // --------------------------------------------------------------------------- // Props (public API) @@ -119,6 +127,7 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; + skills?: ReadonlyArray>; onIsAtEndChange: (isAtEnd: boolean) => void; } @@ -147,6 +156,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ resolvedTheme, timestampFormat, workspaceRoot, + skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange, }: MessagesTimelineProps) { const rawRows = useMemo( @@ -202,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ markdownCwd, resolvedTheme, workspaceRoot, + skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, @@ -213,6 +224,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ markdownCwd, resolvedTheme, workspaceRoot, + skills, activeThreadEnvironmentId, onRevertUserMessage, onImageExpand, @@ -357,6 +369,7 @@ function UserTimelineRow({ row }: { row: Extract )}
    @@ -405,6 +418,7 @@ function AssistantTimelineRow({ row }: { row: Extract >; }) { if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( @@ -745,7 +760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { if (matchIndex > cursor) { inlineNodes.push( - {props.text.slice(cursor, matchIndex)} + , ); } @@ -762,7 +777,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { if (cursor < props.text.length) { inlineNodes.push( - {props.text.slice(cursor)} + , ); } @@ -790,7 +805,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { } if (props.text.length > 0) { - inlineNodes.push({props.text}); + inlineNodes.push( + + + , + ); } else if (inlinePrefix.length === 0) { return null; } @@ -808,7 +827,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { return (
    - {props.text} +
    ); }); diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx new file mode 100644 index 00000000000..0c1abbd40dc --- /dev/null +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -0,0 +1,87 @@ +import { Children, cloneElement, isValidElement, type ReactNode } from "react"; +import type { ServerProviderSkill } from "@t3tools/contracts"; + +import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; +import { + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + SKILL_CHIP_ICON_SVG, +} from "../composerInlineChip"; + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +type InlineSkill = Pick; + +export function SkillInlineText(props: { text: string; skills: ReadonlyArray }) { + const nodes: ReactNode[] = []; + let cursor = 0; + + for (const match of props.text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + const start = (match.index ?? 0) + prefix.length; + const rawText = `$${name}`; + const skill = props.skills.find((candidate) => candidate.name === name); + if (!skill) { + continue; + } + + if (start > cursor) { + nodes.push(props.text.slice(cursor, start)); + } + nodes.push(); + cursor = start + rawText.length; + } + + if (cursor === 0) { + return <>{props.text}; + } + if (cursor < props.text.length) { + nodes.push(props.text.slice(cursor)); + } + return <>{nodes}; +} + +export function renderSkillInlineMarkdownChildren( + children: ReactNode, + skills: ReadonlyArray, +): ReactNode { + return Children.map(children, (child) => { + if (typeof child === "string") { + return ; + } + if (!isValidElement<{ children?: ReactNode }>(child)) { + return child; + } + if (child.type === "code" || child.type === "a") { + return child; + } + if (!("children" in child.props)) { + return child; + } + return cloneElement( + child, + undefined, + renderSkillInlineMarkdownChildren(child.props.children, skills), + ); + }); +} + +function SkillChip(props: { skill: InlineSkill; rawText: string }) { + return ( + + {props.rawText} + + ); +} diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index bf869ee31de..411ad42b146 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -8,5 +8,7 @@ export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leadi export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = "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"; +export const SKILL_CHIP_ICON_SVG = ``; + export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = "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";