| author | |
| committer | |
| log | 11f40556de8a37c7f16486c23fb6bce058275557 |
| tree | 2a9fa318406a0ae2148c4bddd9ed385c0feaf33c |
| parent | 1bcfc88f589051c92882151788217c1410141ef7 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 134 insertions(+), 8 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+18-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; | 1 | import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; |
| 2 | import { CheckIcon, CopyIcon } from "lucide-react"; | 2 | import { CheckIcon, CopyIcon } from "lucide-react"; |
| 3 | import type { ServerProviderSkill } from "@t3tools/contracts"; | ||
| 3 | import React, { | 4 | import React, { |
| 4 | Children, | 5 | Children, |
| 5 | Suspense, | 6 | Suspense, |
| ... | @@ -19,6 +20,7 @@ import ReactMarkdown from "react-markdown"; | ... | @@ -19,6 +20,7 @@ import ReactMarkdown from "react-markdown"; |
| 19 | import { defaultUrlTransform } from "react-markdown"; | 20 | import { defaultUrlTransform } from "react-markdown"; |
| 20 | import remarkGfm from "remark-gfm"; | 21 | import remarkGfm from "remark-gfm"; |
| 21 | import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; | 22 | import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; |
| 23 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; | ||
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 23 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 25 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 24 | import { openInPreferredEditor } from "../editorPreferences"; | 26 | import { openInPreferredEditor } from "../editorPreferences"; |
| ... | @@ -59,8 +61,11 @@ interface ChatMarkdownProps { | ... | @@ -59,8 +61,11 @@ interface ChatMarkdownProps { |
| 59 | text: string; | 61 | text: string; |
| 60 | cwd: string | undefined; | 62 | cwd: string | undefined; |
| 61 | isStreaming?: boolean; | 63 | isStreaming?: boolean; |
| 64 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | ||
| 62 | } | 65 | } |
| 63 | 66 | ||
| 67 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | ||
| 68 | |||
| 64 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; | 69 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; |
| 65 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; | 70 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; |
| 66 | const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; | 71 | const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; |
| ... | @@ -507,7 +512,12 @@ function areMarkdownFileLinkPropsEqual( | ... | @@ -507,7 +512,12 @@ function areMarkdownFileLinkPropsEqual( |
| 507 | ); | 512 | ); |
| 508 | } | 513 | } |
| 509 | 514 | ||
| 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) { | ||
| 511 | const { resolvedTheme } = useTheme(); | 521 | const { resolvedTheme } = useTheme(); |
| 512 | const diffThemeName = resolveDiffThemeName(resolvedTheme); | 522 | const diffThemeName = resolveDiffThemeName(resolvedTheme); |
| 513 | const markdownFileLinkMetaByHref = useMemo(() => { | 523 | const markdownFileLinkMetaByHref = useMemo(() => { |
| ... | @@ -534,6 +544,12 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | ... | @@ -534,6 +544,12 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 534 | }, []); | 544 | }, []); |
| 535 | const markdownComponents = useMemo<Components>( | 545 | const markdownComponents = useMemo<Components>( |
| 536 | () => ({ | 546 | () => ({ |
| 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 | }, | ||
| 537 | a({ node: _node, href, ...props }) { | 553 | a({ node: _node, href, ...props }) { |
| 538 | const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; | 554 | const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; |
| 539 | const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; | 555 | const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; |
| ... | @@ -592,6 +608,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | ... | @@ -592,6 +608,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 592 | isStreaming, | 608 | isStreaming, |
| 593 | markdownFileLinkMetaByHref, | 609 | markdownFileLinkMetaByHref, |
| 594 | resolvedTheme, | 610 | resolvedTheme, |
| 611 | skills, | ||
| 595 | ], | 612 | ], |
| 596 | ); | 613 | ); |
| 597 | 614 |
apps/web/src/components/ChatView.tsx+2| ... | @@ -195,6 +195,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = | ... | @@ -195,6 +195,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 195 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 195 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 196 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; | 196 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; |
| 197 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 197 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 198 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | ||
| 198 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; | 199 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 199 | type EnvironmentUnavailableState = { | 200 | type EnvironmentUnavailableState = { |
| 200 | readonly environmentId: EnvironmentId; | 201 | readonly environmentId: EnvironmentId; |
| ... | @@ -3574,6 +3575,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3574,6 +3575,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3574 | resolvedTheme={resolvedTheme} | 3575 | resolvedTheme={resolvedTheme} |
| 3575 | timestampFormat={timestampFormat} | 3576 | timestampFormat={timestampFormat} |
| 3576 | workspaceRoot={activeWorkspaceRoot} | 3577 | workspaceRoot={activeWorkspaceRoot} |
| 3578 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | ||
| 3577 | onIsAtEndChange={onIsAtEndChange} | 3579 | onIsAtEndChange={onIsAtEndChange} |
| 3578 | /> | 3580 | /> |
| 3579 | 3581 |
apps/web/src/components/ComposerPromptEditor.tsx+1-2| ... | @@ -72,6 +72,7 @@ import { | ... | @@ -72,6 +72,7 @@ import { |
| 72 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | 72 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, |
| 73 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | 73 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, |
| 74 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, | 74 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, |
| 75 | SKILL_CHIP_ICON_SVG, | ||
| 75 | } from "./composerInlineChip"; | 76 | } from "./composerInlineChip"; |
| 76 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; | 77 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; |
| 77 | import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; | 78 | import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; |
| ... | @@ -216,8 +217,6 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { | ... | @@ -216,8 +217,6 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { |
| 216 | return $applyNodeReplacement(new ComposerMentionNode(path)); | 217 | return $applyNodeReplacement(new ComposerMentionNode(path)); |
| 217 | } | 218 | } |
| 218 | 219 | ||
| 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 | |||
| 221 | function resolveSkillDescription( | 220 | function resolveSkillDescription( |
| 222 | skill: Pick<ServerProviderSkill, "shortDescription" | "description">, | 221 | skill: Pick<ServerProviderSkill, "shortDescription" | "description">, |
| 223 | ): string | null { | 222 | ): string | null { |
apps/web/src/components/chat/MessagesTimeline.tsx+24-5| ... | @@ -1,4 +1,9 @@ | ... | @@ -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"; | ||
| 2 | import { | 7 | import { |
| 3 | createContext, | 8 | createContext, |
| 4 | memo, | 9 | memo, |
| ... | @@ -60,6 +65,7 @@ import { | ... | @@ -60,6 +65,7 @@ import { |
| 60 | formatInlineTerminalContextLabel, | 65 | formatInlineTerminalContextLabel, |
| 61 | textContainsInlineTerminalContextLabels, | 66 | textContainsInlineTerminalContextLabels, |
| 62 | } from "./userMessageTerminalContexts"; | 67 | } from "./userMessageTerminalContexts"; |
| 68 | import { SkillInlineText } from "./SkillInlineText"; | ||
| 63 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | 69 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 64 | 70 | ||
| 65 | // --------------------------------------------------------------------------- | 71 | // --------------------------------------------------------------------------- |
| ... | @@ -75,6 +81,7 @@ interface TimelineRowSharedState { | ... | @@ -75,6 +81,7 @@ interface TimelineRowSharedState { |
| 75 | markdownCwd: string | undefined; | 81 | markdownCwd: string | undefined; |
| 76 | resolvedTheme: "light" | "dark"; | 82 | resolvedTheme: "light" | "dark"; |
| 77 | workspaceRoot: string | undefined; | 83 | workspaceRoot: string | undefined; |
| 84 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | ||
| 78 | activeThreadEnvironmentId: EnvironmentId; | 85 | activeThreadEnvironmentId: EnvironmentId; |
| 79 | onRevertUserMessage: (messageId: MessageId) => void; | 86 | onRevertUserMessage: (messageId: MessageId) => void; |
| 80 | onImageExpand: (preview: ExpandedImagePreview) => void; | 87 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| ... | @@ -93,6 +100,7 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); | ... | @@ -93,6 +100,7 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| 93 | const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); | 100 | const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); |
| 94 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; | 101 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 95 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; | 102 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; |
| 103 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | ||
| 96 | 104 | ||
| 97 | // --------------------------------------------------------------------------- | 105 | // --------------------------------------------------------------------------- |
| 98 | // Props (public API) | 106 | // Props (public API) |
| ... | @@ -119,6 +127,7 @@ interface MessagesTimelineProps { | ... | @@ -119,6 +127,7 @@ interface MessagesTimelineProps { |
| 119 | resolvedTheme: "light" | "dark"; | 127 | resolvedTheme: "light" | "dark"; |
| 120 | timestampFormat: TimestampFormat; | 128 | timestampFormat: TimestampFormat; |
| 121 | workspaceRoot: string | undefined; | 129 | workspaceRoot: string | undefined; |
| 130 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | ||
| 122 | onIsAtEndChange: (isAtEnd: boolean) => void; | 131 | onIsAtEndChange: (isAtEnd: boolean) => void; |
| 123 | } | 132 | } |
| 124 | 133 | ||
| ... | @@ -147,6 +156,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -147,6 +156,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 147 | resolvedTheme, | 156 | resolvedTheme, |
| 148 | timestampFormat, | 157 | timestampFormat, |
| 149 | workspaceRoot, | 158 | workspaceRoot, |
| 159 | skills = EMPTY_TIMELINE_SKILLS, | ||
| 150 | onIsAtEndChange, | 160 | onIsAtEndChange, |
| 151 | }: MessagesTimelineProps) { | 161 | }: MessagesTimelineProps) { |
| 152 | const rawRows = useMemo( | 162 | const rawRows = useMemo( |
| ... | @@ -202,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -202,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 202 | markdownCwd, | 212 | markdownCwd, |
| 203 | resolvedTheme, | 213 | resolvedTheme, |
| 204 | workspaceRoot, | 214 | workspaceRoot, |
| 215 | skills, | ||
| 205 | activeThreadEnvironmentId, | 216 | activeThreadEnvironmentId, |
| 206 | onRevertUserMessage, | 217 | onRevertUserMessage, |
| 207 | onImageExpand, | 218 | onImageExpand, |
| ... | @@ -213,6 +224,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -213,6 +224,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 213 | markdownCwd, | 224 | markdownCwd, |
| 214 | resolvedTheme, | 225 | resolvedTheme, |
| 215 | workspaceRoot, | 226 | workspaceRoot, |
| 227 | skills, | ||
| 216 | activeThreadEnvironmentId, | 228 | activeThreadEnvironmentId, |
| 217 | onRevertUserMessage, | 229 | onRevertUserMessage, |
| 218 | onImageExpand, | 230 | onImageExpand, |
| ... | @@ -357,6 +369,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -357,6 +369,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 357 | <UserMessageBody | 369 | <UserMessageBody |
| 358 | text={displayedUserMessage.visibleText} | 370 | text={displayedUserMessage.visibleText} |
| 359 | terminalContexts={terminalContexts} | 371 | terminalContexts={terminalContexts} |
| 372 | skills={ctx.skills} | ||
| 360 | /> | 373 | /> |
| 361 | )} | 374 | )} |
| 362 | <div className="mt-1.5 flex items-center justify-end gap-2"> | 375 | <div className="mt-1.5 flex items-center justify-end gap-2"> |
| ... | @@ -405,6 +418,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -405,6 +418,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 405 | text={messageText} | 418 | text={messageText} |
| 406 | cwd={ctx.markdownCwd} | 419 | cwd={ctx.markdownCwd} |
| 407 | isStreaming={Boolean(row.message.streaming)} | 420 | isStreaming={Boolean(row.message.streaming)} |
| 421 | skills={ctx.skills} | ||
| 408 | /> | 422 | /> |
| 409 | <AssistantChangedFilesSection | 423 | <AssistantChangedFilesSection |
| 410 | turnSummary={row.assistantTurnDiffSummary} | 424 | turnSummary={row.assistantTurnDiffSummary} |
| ... | @@ -744,6 +758,7 @@ const UserMessageTerminalContextInlineLabel = memo( | ... | @@ -744,6 +758,7 @@ const UserMessageTerminalContextInlineLabel = memo( |
| 744 | const UserMessageBody = memo(function UserMessageBody(props: { | 758 | const UserMessageBody = memo(function UserMessageBody(props: { |
| 745 | text: string; | 759 | text: string; |
| 746 | terminalContexts: ParsedTerminalContextEntry[]; | 760 | terminalContexts: ParsedTerminalContextEntry[]; |
| 761 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | ||
| 747 | }) { | 762 | }) { |
| 748 | if (props.terminalContexts.length > 0) { | 763 | if (props.terminalContexts.length > 0) { |
| 749 | const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( | 764 | const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( |
| ... | @@ -766,7 +781,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -766,7 +781,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 766 | if (matchIndex > cursor) { | 781 | if (matchIndex > cursor) { |
| 767 | inlineNodes.push( | 782 | inlineNodes.push( |
| 768 | <span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}> | 783 | <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} /> |
| 770 | </span>, | 785 | </span>, |
| 771 | ); | 786 | ); |
| 772 | } | 787 | } |
| ... | @@ -783,7 +798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -783,7 +798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 783 | if (cursor < props.text.length) { | 798 | if (cursor < props.text.length) { |
| 784 | inlineNodes.push( | 799 | inlineNodes.push( |
| 785 | <span key={`user-message-terminal-context-inline-rest:${cursor}`}> | 800 | <span key={`user-message-terminal-context-inline-rest:${cursor}`}> |
| 786 | {props.text.slice(cursor)} | 801 | <SkillInlineText text={props.text.slice(cursor)} skills={props.skills} /> |
| 787 | </span>, | 802 | </span>, |
| 788 | ); | 803 | ); |
| 789 | } | 804 | } |
| ... | @@ -811,7 +826,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -811,7 +826,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 811 | } | 826 | } |
| 812 | 827 | ||
| 813 | if (props.text.length > 0) { | 828 | 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 | ); | ||
| 815 | } else if (inlinePrefix.length === 0) { | 834 | } else if (inlinePrefix.length === 0) { |
| 816 | return null; | 835 | return null; |
| 817 | } | 836 | } |
| ... | @@ -829,7 +848,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -829,7 +848,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 829 | 848 | ||
| 830 | return ( | 849 | return ( |
| 831 | <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground"> | 850 | <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} /> |
| 833 | </div> | 852 | </div> |
| 834 | ); | 853 | ); |
| 835 | }); | 854 | }); |
apps/web/src/components/chat/SkillInlineText.tsx created+87| ... | @@ -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+2| ... | @@ -8,5 +8,7 @@ export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leadi | ... | @@ -8,5 +8,7 @@ export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leadi |
| 8 | export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = | 8 | export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = |
| 9 | "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"; | 9 | "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"; |
| 10 | 10 | ||
| 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 | |||
| 11 | export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = | 13 | export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = |
| 12 | "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"; | 14 | "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"; |