| author | |
| committer | |
| log | c1e70b5f8c93428c1fe7af62f24ee6ff2bffff6e |
| tree | 36fa190a9548fb31531e43dcc816f6404bc131e2 |
| parent | e09b88b6a51bb7e4309498f01c2f36bf5ada43f4 |
| signature | Signed by PGP key B5690EEEBB952194 |
19 files changed, 1558 insertions(+), 32 deletions(-)
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+23| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | import { | |
| 3 | appendCodexArtifactTemplateUsePrompt, | |
| 4 | type CodexArtifactTemplate, | |
| 5 | } from "@t3tools/client-runtime/codex-artifact-templates"; | |
| 2 | 6 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; |
| 3 | 7 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 4 | 8 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| ... | ... | @@ -260,6 +264,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 260 | 264 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 261 | 265 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 262 | 266 | const composerEditorRef = useRef<ComposerEditorHandle>(null); |
| 267 | const draftMessageRef = useRef(props.draftMessage); | |
| 268 | draftMessageRef.current = props.draftMessage; | |
| 263 | 269 | const composerOverlayRef = useRef<View>(null); |
| 264 | 270 | const listRef = useRef<LegendListRef>(null); |
| 265 | 271 | const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); |
| ... | ... | @@ -601,6 +607,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 601 | 607 | composerEditorRef.current?.blur(); |
| 602 | 608 | }, []); |
| 603 | 609 | |
| 610 | const handleUseArtifactTemplate = useCallback( | |
| 611 | (template: CodexArtifactTemplate) => { | |
| 612 | const currentDraft = draftMessageRef.current; | |
| 613 | const nextDraft = appendCodexArtifactTemplateUsePrompt(currentDraft, template); | |
| 614 | if (nextDraft !== currentDraft) { | |
| 615 | draftMessageRef.current = nextDraft; | |
| 616 | props.onChangeDraftMessage(nextDraft); | |
| 617 | } | |
| 618 | requestAnimationFrame(() => { | |
| 619 | composerEditorRef.current?.focus(); | |
| 620 | composerEditorRef.current?.setSelection({ start: nextDraft.length, end: nextDraft.length }); | |
| 621 | }); | |
| 622 | }, | |
| 623 | [props.onChangeDraftMessage], | |
| 624 | ); | |
| 625 | ||
| 604 | 626 | const handleScrollToEnd = useCallback(() => { |
| 605 | 627 | void Haptics.selectionAsync(); |
| 606 | 628 | void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { |
| ... | ... | @@ -677,6 +699,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 677 | 699 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} |
| 678 | 700 | onEndFollowEnabledChange={setEndFollowEnabled} |
| 679 | 701 | skills={selectedProviderSkills} |
| 702 | onUseArtifactTemplate={handleUseArtifactTemplate} | |
| 680 | 703 | loadEarlier={props.loadEarlier ?? null} |
| 681 | 704 | /> |
| 682 | 705 | </View> |
apps/mobile/src/features/threads/ThreadFeed.tsx+135-24| ... | ... | @@ -10,12 +10,20 @@ import type { |
| 10 | 10 | ThreadId, |
| 11 | 11 | TurnId, |
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | import { | |
| 14 | codexArtifactTemplatePresentationLabel, | |
| 15 | type CodexArtifactTemplate, | |
| 16 | } from "@t3tools/client-runtime/codex-artifact-templates"; | |
| 13 | 17 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 14 | 18 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 15 | 19 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 16 | 20 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; |
| 21 | import { | |
| 22 | renderCodexFileCitationsAsMarkdown, | |
| 23 | splitCodexArtifactTemplateMarkdown, | |
| 24 | } from "@t3tools/client-runtime/codex-markdown-directives"; | |
| 17 | 25 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 18 | import { SymbolView } from "../../components/AppSymbol"; | |
| 26 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; | |
| 19 | 27 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 20 | 28 | import { useFocusEffect, useNavigation } from "@react-navigation/native"; |
| 21 | 29 | import { |
| ... | ... | @@ -190,6 +198,7 @@ export interface ThreadFeedProps { |
| 190 | 198 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| 191 | 199 | readonly onEndFollowEnabledChange?: (enabled: boolean) => void; |
| 192 | 200 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 201 | readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; | |
| 193 | 202 | /** Non-null when older turns exist beyond the loaded window. */ |
| 194 | 203 | readonly loadEarlier?: { |
| 195 | 204 | readonly loading: boolean; |
| ... | ... | @@ -568,6 +577,115 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { |
| 568 | 577 | ); |
| 569 | 578 | }); |
| 570 | 579 | |
| 580 | const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< | |
| 581 | CodexArtifactTemplate["artifactKind"], | |
| 582 | AppSymbolName | |
| 583 | > = { | |
| 584 | document: "doc.text", | |
| 585 | presentation: "chart.bar.xaxis", | |
| 586 | spreadsheet: "chart.bar.xaxis", | |
| 587 | site: "safari", | |
| 588 | "google-docs": "doc.text", | |
| 589 | "google-slides": "chart.bar.xaxis", | |
| 590 | "google-sheets": "chart.bar.xaxis", | |
| 591 | image: "camera", | |
| 592 | email: "text.bubble", | |
| 593 | slack: "text.bubble", | |
| 594 | }; | |
| 595 | ||
| 596 | function ArtifactTemplateCard(props: { | |
| 597 | readonly template: CodexArtifactTemplate; | |
| 598 | readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; | |
| 599 | }) { | |
| 600 | return ( | |
| 601 | <View className="my-2 min-w-0 flex-row items-center gap-3 rounded-2xl border border-border bg-card px-3 py-3"> | |
| 602 | <View className="relative h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border bg-subtle"> | |
| 603 | <SymbolView | |
| 604 | name={ARTIFACT_TEMPLATE_SYMBOL_BY_KIND[props.template.artifactKind]} | |
| 605 | size={20} | |
| 606 | tintColorClassName="accent-foreground-muted" | |
| 607 | type="monochrome" | |
| 608 | /> | |
| 609 | <View className="absolute -right-1 -bottom-1 h-4 w-4 items-center justify-center rounded-full bg-fuchsia-500"> | |
| 610 | <SymbolView | |
| 611 | name={{ ios: "sparkles", android: "auto_awesome" }} | |
| 612 | size={9} | |
| 613 | tintColor="white" | |
| 614 | type="monochrome" | |
| 615 | /> | |
| 616 | </View> | |
| 617 | </View> | |
| 618 | <View className="min-w-0 flex-1"> | |
| 619 | <Text className="font-t3-bold text-sm text-foreground" numberOfLines={1}> | |
| 620 | {props.template.displayName} | |
| 621 | </Text> | |
| 622 | <Text className="text-xs text-foreground-muted"> | |
| 623 | {codexArtifactTemplatePresentationLabel(props.template.artifactKind)} | |
| 624 | </Text> | |
| 625 | </View> | |
| 626 | {props.onUse ? ( | |
| 627 | <Pressable | |
| 628 | accessibilityRole="button" | |
| 629 | accessibilityLabel={`Use ${props.template.displayName} template`} | |
| 630 | className="min-h-9 justify-center rounded-lg border border-border bg-subtle px-3 active:opacity-65" | |
| 631 | onPress={() => props.onUse?.(props.template)} | |
| 632 | > | |
| 633 | <Text className="font-t3-bold text-xs text-foreground">Use template</Text> | |
| 634 | </Pressable> | |
| 635 | ) : null} | |
| 636 | </View> | |
| 637 | ); | |
| 638 | } | |
| 639 | ||
| 640 | const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { | |
| 641 | readonly markdown: string; | |
| 642 | readonly markdownStyles: MarkdownStyleSet; | |
| 643 | readonly onLinkPress: (href: string) => void; | |
| 644 | readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; | |
| 645 | readonly renderImage: MarkdownImageRenderer; | |
| 646 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined; | |
| 647 | }) { | |
| 648 | const segments = useMemo( | |
| 649 | () => splitCodexArtifactTemplateMarkdown(props.markdown), | |
| 650 | [props.markdown], | |
| 651 | ); | |
| 652 | ||
| 653 | return segments.map((segment) => { | |
| 654 | if (segment.kind === "artifact-template") { | |
| 655 | return ( | |
| 656 | <ArtifactTemplateCard | |
| 657 | key={`artifact-template:${segment.sourceOffset}`} | |
| 658 | template={segment.template} | |
| 659 | onUse={props.onUseArtifactTemplate} | |
| 660 | /> | |
| 661 | ); | |
| 662 | } | |
| 663 | if (segment.markdown.trim().length === 0) return null; | |
| 664 | ||
| 665 | const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); | |
| 666 | return hasNativeSelectableMarkdownText() ? ( | |
| 667 | <SelectableMarkdownText | |
| 668 | key={`markdown:${segment.sourceOffset}`} | |
| 669 | markdown={markdown} | |
| 670 | skills={props.skills} | |
| 671 | textStyle={props.markdownStyles.nativeTextStyle} | |
| 672 | onLinkPress={props.onLinkPress} | |
| 673 | renderImage={props.renderImage} | |
| 674 | /> | |
| 675 | ) : ( | |
| 676 | <Markdown | |
| 677 | key={`markdown:${segment.sourceOffset}`} | |
| 678 | options={{ gfm: true }} | |
| 679 | renderers={props.markdownStyles.renderers} | |
| 680 | styles={props.markdownStyles.styles} | |
| 681 | theme={props.markdownStyles.theme} | |
| 682 | > | |
| 683 | {markdown} | |
| 684 | </Markdown> | |
| 685 | ); | |
| 686 | }); | |
| 687 | }); | |
| 688 | ||
| 571 | 689 | function MarkdownCodeBlock(props: { |
| 572 | 690 | readonly backgroundColor: string; |
| 573 | 691 | readonly borderColor: string; |
| ... | ... | @@ -1099,7 +1217,7 @@ function useMarkdownStyles( |
| 1099 | 1217 | |
| 1100 | 1218 | function renderFeedEntry( |
| 1101 | 1219 | info: { item: ThreadFeedEntry; index: number }, |
| 1102 | props: Pick<ThreadFeedProps, "environmentId" | "skills"> & { | |
| 1220 | props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & { | |
| 1103 | 1221 | readonly copiedRowId: string | null; |
| 1104 | 1222 | readonly expandedWorkRows: Record<string, boolean>; |
| 1105 | 1223 | readonly terminalAssistantMessageIds: ReadonlySet<string>; |
| ... | ... | @@ -1162,6 +1280,7 @@ function renderFeedEntry( |
| 1162 | 1280 | if (entry.type === "message") { |
| 1163 | 1281 | const { message } = entry; |
| 1164 | 1282 | const isUser = message.role === "user"; |
| 1283 | const renderedText = message.text; | |
| 1165 | 1284 | const styles = isUser ? markdownStyles.user : markdownStyles.assistant; |
| 1166 | 1285 | const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); |
| 1167 | 1286 | const attachments = message.attachments ?? []; |
| ... | ... | @@ -1171,7 +1290,7 @@ function renderFeedEntry( |
| 1171 | 1290 | // children during the unclamped pass and never moves them once the width |
| 1172 | 1291 | // is clamped, so the paragraphs around the block end up drawn on top of |
| 1173 | 1292 | // each other. Pinning the width removes that pass. |
| 1174 | const hasWideBlock = hasWideMarkdownBlock(message.text, WIDE_MARKDOWN_BLOCK_OPTIONS); | |
| 1293 | const hasWideBlock = hasWideMarkdownBlock(renderedText, WIDE_MARKDOWN_BLOCK_OPTIONS); | |
| 1175 | 1294 | const assistantTurnStillInProgress = |
| 1176 | 1295 | message.role === "assistant" && |
| 1177 | 1296 | props.unsettledTurnId !== null && |
| ... | ... | @@ -1251,7 +1370,7 @@ function renderFeedEntry( |
| 1251 | 1370 | |
| 1252 | 1371 | // Skip empty assistant messages (no text, no attachments) — they would |
| 1253 | 1372 | // render as an orphaned timestamp and break adjacent activity-group merging. |
| 1254 | if (message.text.trim().length === 0 && attachments.length === 0) { | |
| 1373 | if (renderedText.trim().length === 0 && attachments.length === 0) { | |
| 1255 | 1374 | return null; |
| 1256 | 1375 | } |
| 1257 | 1376 | |
| ... | ... | @@ -1261,25 +1380,15 @@ function renderFeedEntry( |
| 1261 | 1380 | className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")} |
| 1262 | 1381 | {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} |
| 1263 | 1382 | > |
| 1264 | {message.text.trim().length > 0 ? ( | |
| 1265 | hasNativeSelectableMarkdownText() ? ( | |
| 1266 | <SelectableMarkdownText | |
| 1267 | markdown={message.text} | |
| 1268 | skills={props.skills} | |
| 1269 | textStyle={styles.nativeTextStyle} | |
| 1270 | onLinkPress={props.onMarkdownLinkPress} | |
| 1271 | renderImage={props.renderMarkdownImage} | |
| 1272 | /> | |
| 1273 | ) : ( | |
| 1274 | <Markdown | |
| 1275 | options={{ gfm: true }} | |
| 1276 | renderers={styles.renderers} | |
| 1277 | styles={styles.styles} | |
| 1278 | theme={styles.theme} | |
| 1279 | > | |
| 1280 | {message.text} | |
| 1281 | </Markdown> | |
| 1282 | ) | |
| 1383 | {renderedText.trim().length > 0 ? ( | |
| 1384 | <AssistantMarkdownContent | |
| 1385 | markdown={renderedText} | |
| 1386 | markdownStyles={styles} | |
| 1387 | onLinkPress={props.onMarkdownLinkPress} | |
| 1388 | onUseArtifactTemplate={props.onUseArtifactTemplate} | |
| 1389 | renderImage={props.renderMarkdownImage} | |
| 1390 | skills={props.skills} | |
| 1391 | /> | |
| 1283 | 1392 | ) : null} |
| 1284 | 1393 | {attachments.map((attachment) => { |
| 1285 | 1394 | return isImageAttachment(attachment) ? ( |
| ... | ... | @@ -1304,7 +1413,7 @@ function renderFeedEntry( |
| 1304 | 1413 | <View className="mt-1 flex-row items-center gap-1"> |
| 1305 | 1414 | <CopyTextButton |
| 1306 | 1415 | accessibilityLabel="Copy message" |
| 1307 | text={message.text} | |
| 1416 | text={renderedText} | |
| 1308 | 1417 | tintColor={iconSubtleColor} |
| 1309 | 1418 | buttonSize={28} |
| 1310 | 1419 | iconSize={13} |
| ... | ... | @@ -2192,6 +2301,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2192 | 2301 | reviewCommentBubbleWidth, |
| 2193 | 2302 | userBubbleMaxWidth, |
| 2194 | 2303 | skills: props.skills, |
| 2304 | onUseArtifactTemplate: props.onUseArtifactTemplate, | |
| 2195 | 2305 | })} |
| 2196 | 2306 | </Animated.View> |
| 2197 | 2307 | ), |
| ... | ... | @@ -2214,6 +2324,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2214 | 2324 | onToggleWorkGroup, |
| 2215 | 2325 | onToggleWorkRow, |
| 2216 | 2326 | props.environmentId, |
| 2327 | props.onUseArtifactTemplate, | |
| 2217 | 2328 | props.skills, |
| 2218 | 2329 | renderMarkdownImage, |
| 2219 | 2330 | ], |
apps/web/src/components/ChatMarkdown.test.tsx+208| ... | ... | @@ -102,6 +102,214 @@ describe("ChatMarkdown file option chips", () => { |
| 102 | 102 | expect(html).toContain('aria-haspopup="menu"'); |
| 103 | 103 | expect(html).toContain("select-text"); |
| 104 | 104 | }); |
| 105 | ||
| 106 | it.each([true, false])( | |
| 107 | "renders Codex file citations as file chips with parseRawHtml=%s", | |
| 108 | (parseRawHtml) => { | |
| 109 | const html = renderToStaticMarkup( | |
| 110 | <ChatMarkdown | |
| 111 | cwd="/tmp/project" | |
| 112 | text={ | |
| 113 | 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.' | |
| 114 | } | |
| 115 | lineBreaks={!parseRawHtml} | |
| 116 | parseRawHtml={parseRawHtml} | |
| 117 | />, | |
| 118 | ); | |
| 119 | ||
| 120 | expect(html).not.toContain("codex-file-citation"); | |
| 121 | expect(html).toContain("chat-markdown-file-link"); | |
| 122 | expect(html).toContain( | |
| 123 | 'data-markdown-copy="[report.xlsx](/tmp/project/outputs/report.xlsx)"', | |
| 124 | ); | |
| 125 | expect(html).toContain("report.xlsx"); | |
| 126 | }, | |
| 127 | ); | |
| 128 | ||
| 129 | it("leaves an unfinished streaming citation visible until it is complete", () => { | |
| 130 | const html = renderToStaticMarkup( | |
| 131 | <ChatMarkdown | |
| 132 | cwd="/tmp/project" | |
| 133 | text={'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"'} | |
| 134 | isStreaming | |
| 135 | />, | |
| 136 | ); | |
| 137 | ||
| 138 | expect(html).toContain(":codex-file-citation"); | |
| 139 | expect(html).not.toContain("chat-markdown-file-link"); | |
| 140 | }); | |
| 141 | ||
| 142 | it("leaves malformed and similarly named file directives literal", () => { | |
| 143 | for (const text of [ | |
| 144 | ':codex-file-citation{purpose="output"}', | |
| 145 | ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}', | |
| 146 | ]) { | |
| 147 | const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />); | |
| 148 | ||
| 149 | expect(html).toContain(text.replaceAll('"', "&quot;")); | |
| 150 | expect(html).not.toContain("chat-markdown-file-link"); | |
| 151 | } | |
| 152 | }); | |
| 153 | ||
| 154 | it("preserves Codex file citation examples inside code", () => { | |
| 155 | const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; | |
| 156 | const html = renderToStaticMarkup( | |
| 157 | <ChatMarkdown | |
| 158 | cwd="/tmp/project" | |
| 159 | text={`Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``} | |
| 160 | />, | |
| 161 | ); | |
| 162 | ||
| 163 | expect(html.match(/:codex-file-citation/g)).toHaveLength(2); | |
| 164 | expect(html).not.toContain("chat-markdown-file-link"); | |
| 165 | }); | |
| 166 | ||
| 167 | it("preserves escaped Codex file citations as literal text", () => { | |
| 168 | const html = renderToStaticMarkup( | |
| 169 | <ChatMarkdown | |
| 170 | cwd="/tmp/project" | |
| 171 | text={'Example: \\:codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'} | |
| 172 | />, | |
| 173 | ); | |
| 174 | ||
| 175 | expect(html).toContain(":codex-file-citation"); | |
| 176 | expect(html).not.toContain("chat-markdown-file-link"); | |
| 177 | }); | |
| 178 | ||
| 179 | it("does not create a nested link for citations inside link text", () => { | |
| 180 | const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; | |
| 181 | const html = renderToStaticMarkup( | |
| 182 | <ChatMarkdown cwd="/tmp/project" text={`[See ${directive}](https://example.com)`} />, | |
| 183 | ); | |
| 184 | const renderedText = html.replace(/<[^>]+>/g, ""); | |
| 185 | ||
| 186 | expect(renderedText).toContain("codex-file-citation"); | |
| 187 | expect(html).not.toContain("chat-markdown-file-link"); | |
| 188 | }); | |
| 189 | ||
| 190 | it("renders file citations created by over-indented list recovery", () => { | |
| 191 | const html = renderToStaticMarkup( | |
| 192 | <ChatMarkdown | |
| 193 | cwd="/tmp/project" | |
| 194 | text={'- Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'} | |
| 195 | />, | |
| 196 | ); | |
| 197 | ||
| 198 | expect(html).not.toContain("<pre>"); | |
| 199 | expect(html).toContain("Created "); | |
| 200 | expect(html).toContain("chat-markdown-file-link"); | |
| 201 | expect(html).toContain("report.xlsx"); | |
| 202 | }); | |
| 203 | ||
| 204 | it("disambiguates Codex citations with the same basename", () => { | |
| 205 | const html = renderToStaticMarkup( | |
| 206 | <ChatMarkdown | |
| 207 | cwd="/tmp/project" | |
| 208 | text={ | |
| 209 | 'Changed :codex-file-citation{path="/tmp/project/src/index.ts"} and :codex-file-citation{path="/tmp/project/test/index.ts"}.' | |
| 210 | } | |
| 211 | />, | |
| 212 | ); | |
| 213 | ||
| 214 | expect(html).toContain("index.ts · project/src"); | |
| 215 | expect(html).toContain("index.ts · project/test"); | |
| 216 | }); | |
| 217 | ||
| 218 | it("preserves rejected citations created by over-indented list recovery", () => { | |
| 219 | const malformedHtml = renderToStaticMarkup( | |
| 220 | <ChatMarkdown | |
| 221 | cwd="/tmp/project" | |
| 222 | text={'Leading text before list.\n\n- Bad :codex-file-citation{purpose="output"}'} | |
| 223 | />, | |
| 224 | ); | |
| 225 | const nestedLinkHtml = renderToStaticMarkup( | |
| 226 | <ChatMarkdown | |
| 227 | cwd="/tmp/project" | |
| 228 | text={ | |
| 229 | 'Leading text before list.\n\n- [Bad :codex-file-citation{path="/tmp/project/report.xlsx"}](https://example.com)' | |
| 230 | } | |
| 231 | />, | |
| 232 | ); | |
| 233 | const nestedLinkText = nestedLinkHtml.replace(/<[^>]+>/g, ""); | |
| 234 | ||
| 235 | expect(malformedHtml).toContain( | |
| 236 | "<li>Bad :codex-file-citation{purpose=&quot;output&quot;}</li>", | |
| 237 | ); | |
| 238 | expect(nestedLinkText).toContain( | |
| 239 | "Bad :codex-file-citation{path=&quot;/tmp/project/report.xlsx&quot;}", | |
| 240 | ); | |
| 241 | }); | |
| 242 | }); | |
| 243 | ||
| 244 | const ARTIFACT_TEMPLATE_DIRECTIVE = | |
| 245 | '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; | |
| 246 | ||
| 247 | describe("ChatMarkdown artifact-template cards", () => { | |
| 248 | it.each([true, false])("renders the Codex result card with parseRawHtml=%s", (parseRawHtml) => { | |
| 249 | const html = renderToStaticMarkup( | |
| 250 | <ChatMarkdown | |
| 251 | cwd="/tmp/project" | |
| 252 | text={ARTIFACT_TEMPLATE_DIRECTIVE} | |
| 253 | parseRawHtml={parseRawHtml} | |
| 254 | onUseArtifactTemplate={() => undefined} | |
| 255 | />, | |
| 256 | ); | |
| 257 | ||
| 258 | expect(html).not.toContain("::artifact-template"); | |
| 259 | expect(html).toContain("chat-markdown-artifact-template"); | |
| 260 | expect(html).toContain('data-artifact-kind="document"'); | |
| 261 | expect(html).toContain('data-markdown-copy="Hello World (Document template)\n\n"'); | |
| 262 | expect(html).toContain('data-skill-name="artifact-template-hello-world"'); | |
| 263 | expect(html).toContain("Hello World"); | |
| 264 | expect(html).toContain("Document template"); | |
| 265 | expect(html).toContain("Use template"); | |
| 266 | expect(html).not.toContain("<p><div"); | |
| 267 | }); | |
| 268 | ||
| 269 | it("renders a passive card outside a composer-backed timeline", () => { | |
| 270 | const html = renderToStaticMarkup( | |
| 271 | <ChatMarkdown cwd="/tmp/project" text={ARTIFACT_TEMPLATE_DIRECTIVE} />, | |
| 272 | ); | |
| 273 | ||
| 274 | expect(html).toContain("chat-markdown-artifact-template"); | |
| 275 | expect(html).not.toContain("Use template"); | |
| 276 | }); | |
| 277 | ||
| 278 | it("leaves malformed and unfinished artifact-template directives literal", () => { | |
| 279 | const malformed = | |
| 280 | '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; | |
| 281 | const unfinished = ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1); | |
| 282 | ||
| 283 | for (const text of [malformed, unfinished]) { | |
| 284 | const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />); | |
| 285 | expect(html).toContain("::artifact-template"); | |
| 286 | expect(html).not.toContain("chat-markdown-artifact-template"); | |
| 287 | } | |
| 288 | }); | |
| 289 | ||
| 290 | it("leaves escaped and similarly named artifact-template directives literal", () => { | |
| 291 | for (const text of [ | |
| 292 | `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`, | |
| 293 | ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"), | |
| 294 | ]) { | |
| 295 | const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />); | |
| 296 | ||
| 297 | expect(html).toContain("::artifact-template"); | |
| 298 | expect(html).not.toContain("chat-markdown-artifact-template"); | |
| 299 | } | |
| 300 | }); | |
| 301 | ||
| 302 | it("preserves artifact-template examples inside code", () => { | |
| 303 | const html = renderToStaticMarkup( | |
| 304 | <ChatMarkdown | |
| 305 | cwd="/tmp/project" | |
| 306 | text={`\`${ARTIFACT_TEMPLATE_DIRECTIVE}\`\n\n\`\`\`text\n${ARTIFACT_TEMPLATE_DIRECTIVE}\n\`\`\``} | |
| 307 | />, | |
| 308 | ); | |
| 309 | ||
| 310 | expect(html.match(/::artifact-template/g)).toHaveLength(2); | |
| 311 | expect(html).not.toContain("chat-markdown-artifact-template"); | |
| 312 | }); | |
| 105 | 313 | }); |
| 106 | 314 | |
| 107 | 315 | describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { |
apps/web/src/components/ChatMarkdown.tsx+94-1| ... | ... | @@ -3,15 +3,23 @@ import { |
| 3 | 3 | CheckIcon, |
| 4 | 4 | ChevronRightIcon, |
| 5 | 5 | CopyIcon, |
| 6 | FileSpreadsheetIcon, | |
| 7 | FileTextIcon, | |
| 6 | 8 | GlobeIcon, |
| 9 | ImageIcon, | |
| 7 | 10 | InfoIcon, |
| 8 | 11 | LightbulbIcon, |
| 12 | MailIcon, | |
| 9 | 13 | Maximize2Icon, |
| 14 | MessageSquareIcon, | |
| 10 | 15 | MessageSquareWarningIcon, |
| 11 | 16 | Minimize2Icon, |
| 12 | 17 | OctagonAlertIcon, |
| 18 | PresentationIcon, | |
| 19 | SparklesIcon, | |
| 13 | 20 | TriangleAlertIcon, |
| 14 | 21 | WrapTextIcon, |
| 22 | type LucideIcon, | |
| 15 | 23 | } from "lucide-react"; |
| 16 | 24 | import type { |
| 17 | 25 | EnvironmentId, |
| ... | ... | @@ -24,6 +32,11 @@ import { |
| 24 | 32 | squashAtomCommandFailure, |
| 25 | 33 | type AtomCommandResult, |
| 26 | 34 | } from "@t3tools/client-runtime/state/runtime"; |
| 35 | import { | |
| 36 | codexArtifactTemplatePresentationLabel, | |
| 37 | type CodexArtifactTemplate, | |
| 38 | type CodexArtifactTemplateKind, | |
| 39 | } from "@t3tools/client-runtime/codex-artifact-templates"; | |
| 27 | 40 | import { |
| 28 | 41 | classifyMarkdownImageSource, |
| 29 | 42 | markdownImageSourceFragment, |
| ... | ... | @@ -55,6 +68,12 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; |
| 55 | 68 | import remarkBreaks from "remark-breaks"; |
| 56 | 69 | import remarkGfm from "remark-gfm"; |
| 57 | 70 | import { remarkGithubAlerts } from "../markdown-github-alerts"; |
| 71 | import { | |
| 72 | artifactTemplateFromHastProperties, | |
| 73 | CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, | |
| 74 | remarkCodexDirectives, | |
| 75 | renderCodexFileCitationsAsMarkdown, | |
| 76 | } from "@t3tools/client-runtime/codex-markdown-directives"; | |
| 58 | 77 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 59 | 78 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 60 | 79 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; |
| ... | ... | @@ -156,6 +175,8 @@ interface ChatMarkdownProps { |
| 156 | 175 | lineBreaks?: boolean; |
| 157 | 176 | /** Parse sanitized raw HTML instead of displaying its source text. */ |
| 158 | 177 | parseRawHtml?: boolean; |
| 178 | /** Append a prompt that invokes a newly created artifact-template skill. */ | |
| 179 | onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; | |
| 159 | 180 | imageBaseDir?: string | undefined; |
| 160 | 181 | onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 161 | 182 | } |
| ... | ... | @@ -191,6 +212,64 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { |
| 191 | 212 | |
| 192 | 213 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 193 | 214 | |
| 215 | const ARTIFACT_TEMPLATE_ICON_BY_KIND = { | |
| 216 | document: FileTextIcon, | |
| 217 | presentation: PresentationIcon, | |
| 218 | spreadsheet: FileSpreadsheetIcon, | |
| 219 | site: GlobeIcon, | |
| 220 | "google-docs": FileTextIcon, | |
| 221 | "google-slides": PresentationIcon, | |
| 222 | "google-sheets": FileSpreadsheetIcon, | |
| 223 | image: ImageIcon, | |
| 224 | email: MailIcon, | |
| 225 | slack: MessageSquareIcon, | |
| 226 | } satisfies Record<CodexArtifactTemplateKind, LucideIcon>; | |
| 227 | ||
| 228 | function CodexArtifactTemplateCard(props: { | |
| 229 | readonly template: CodexArtifactTemplate; | |
| 230 | readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; | |
| 231 | }) { | |
| 232 | const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; | |
| 233 | const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); | |
| 234 | ||
| 235 | return ( | |
| 236 | <div | |
| 237 | role="group" | |
| 238 | aria-label={`${props.template.displayName} template`} | |
| 239 | className="chat-markdown-artifact-template my-[0.65rem] flex w-full min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card/60 px-3 py-2.5 text-foreground shadow-xs" | |
| 240 | data-artifact-kind={props.template.artifactKind} | |
| 241 | data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`} | |
| 242 | data-skill-name={props.template.skillName} | |
| 243 | > | |
| 244 | <div className="flex min-w-0 flex-1 items-center gap-3"> | |
| 245 | <span className="relative flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-background text-muted-foreground shadow-xs"> | |
| 246 | <Icon aria-hidden className="size-5" /> | |
| 247 | <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full border border-background bg-fuchsia-500 text-white shadow-xs"> | |
| 248 | <SparklesIcon aria-hidden className="size-2.5" /> | |
| 249 | </span> | |
| 250 | </span> | |
| 251 | <span className="min-w-0"> | |
| 252 | <span className="block truncate text-sm font-medium text-foreground"> | |
| 253 | {props.template.displayName} | |
| 254 | </span> | |
| 255 | <span className="block text-xs text-muted-foreground">{presentationLabel}</span> | |
| 256 | </span> | |
| 257 | </div> | |
| 258 | {props.onUse ? ( | |
| 259 | <Button | |
| 260 | type="button" | |
| 261 | size="sm" | |
| 262 | variant="outline" | |
| 263 | className="shrink-0" | |
| 264 | onClick={() => props.onUse?.(props.template)} | |
| 265 | > | |
| 266 | Use template | |
| 267 | </Button> | |
| 268 | ) : null} | |
| 269 | </div> | |
| 270 | ); | |
| 271 | } | |
| 272 | ||
| 194 | 273 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; |
| 195 | 274 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; |
| 196 | 275 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; |
| ... | ... | @@ -280,6 +359,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 280 | 359 | "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), |
| 281 | 360 | code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], |
| 282 | 361 | blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], |
| 362 | div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], | |
| 283 | 363 | img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], |
| 284 | 364 | }, |
| 285 | 365 | protocols: { |
| ... | ... | @@ -293,6 +373,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ |
| 293 | 373 | remarkGfm, |
| 294 | 374 | remarkGithubAlerts, |
| 295 | 375 | remarkNormalizeListItemIndentation, |
| 376 | remarkCodexDirectives, | |
| 296 | 377 | remarkPreserveCodeMeta, |
| 297 | 378 | remarkNormalizeLinksAndTagInlineCode, |
| 298 | 379 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; |
| ... | ... | @@ -301,6 +382,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 301 | 382 | remarkGfm, |
| 302 | 383 | remarkGithubAlerts, |
| 303 | 384 | remarkNormalizeListItemIndentation, |
| 385 | remarkCodexDirectives, | |
| 304 | 386 | remarkBreaks, |
| 305 | 387 | remarkPreserveCodeMeta, |
| 306 | 388 | remarkNormalizeLinksAndTagInlineCode, |
| ... | ... | @@ -1727,6 +1809,7 @@ function ChatMarkdown({ |
| 1727 | 1809 | className, |
| 1728 | 1810 | lineBreaks = false, |
| 1729 | 1811 | parseRawHtml = true, |
| 1812 | onUseArtifactTemplate, | |
| 1730 | 1813 | imageBaseDir, |
| 1731 | 1814 | onImageExpand, |
| 1732 | 1815 | }: ChatMarkdownProps) { |
| ... | ... | @@ -1793,7 +1876,7 @@ function ChatMarkdown({ |
| 1793 | 1876 | string, |
| 1794 | 1877 | NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>> |
| 1795 | 1878 | >(); |
| 1796 | for (const href of extractMarkdownLinkHrefs(text)) { | |
| 1879 | for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { | |
| 1797 | 1880 | const normalizedHref = normalizeMarkdownLinkHrefKey(href); |
| 1798 | 1881 | if (metaByHref.has(normalizedHref)) continue; |
| 1799 | 1882 | const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); |
| ... | ... | @@ -2037,6 +2120,15 @@ function ChatMarkdown({ |
| 2037 | 2120 | }; |
| 2038 | 2121 | |
| 2039 | 2122 | return { |
| 2123 | div({ node, children, ...props }) { | |
| 2124 | const artifactTemplate = artifactTemplateFromHastProperties(node?.properties); | |
| 2125 | if (artifactTemplate) { | |
| 2126 | return ( | |
| 2127 | <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} /> | |
| 2128 | ); | |
| 2129 | } | |
| 2130 | return <div {...props}>{children}</div>; | |
| 2131 | }, | |
| 2040 | 2132 | p({ node: _node, children, ...props }) { |
| 2041 | 2133 | return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>; |
| 2042 | 2134 | }, |
| ... | ... | @@ -2332,6 +2424,7 @@ function ChatMarkdown({ |
| 2332 | 2424 | isStreaming, |
| 2333 | 2425 | markdownFileLinkMetaByHref, |
| 2334 | 2426 | onTaskListChange, |
| 2427 | onUseArtifactTemplate, | |
| 2335 | 2428 | onImageExpand, |
| 2336 | 2429 | openFileInPanel, |
| 2337 | 2430 | openInPreferredEditor, |
apps/web/src/components/ChatView.logic.test.ts+16| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 10 | |
| 11 | 11 | import type { Thread, ThreadShell } from "../types"; |
| 12 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | |
| 12 | 13 | import { |
| 13 | 14 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, |
| 14 | 15 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| ... | ... | @@ -36,6 +37,7 @@ import { |
| 36 | 37 | scheduleEnvironmentReconnectWarning, |
| 37 | 38 | shoulderTabReserve, |
| 38 | 39 | startNewThreadForProject, |
| 40 | codexArtifactTemplatePromptToAppend, | |
| 39 | 41 | shouldDockDraftHeroForSubmission, |
| 40 | 42 | shouldReleaseTimelineAnchorForToolActivity, |
| 41 | 43 | shouldShowBranchMismatchBanner, |
| ... | ... | @@ -72,6 +74,20 @@ const environmentId = EnvironmentId.make("environment-local"); |
| 72 | 74 | const projectId = ProjectId.make("project-1"); |
| 73 | 75 | const threadId = ThreadId.make("thread-1"); |
| 74 | 76 | const now = "2026-03-29T00:00:00.000Z"; |
| 77 | const helloWorldTemplate: CodexArtifactTemplate = { | |
| 78 | artifactKind: "document", | |
| 79 | displayName: "Hello World", | |
| 80 | skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", | |
| 81 | skillName: "artifact-template-hello-world", | |
| 82 | }; | |
| 83 | ||
| 84 | describe("artifact template composer insertion", () => { | |
| 85 | it("does not insert an already-present prompt", () => { | |
| 86 | const prompt = "Create a document using this $artifact-template-hello-world about…"; | |
| 87 | ||
| 88 | expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); | |
| 89 | }); | |
| 90 | }); | |
| 75 | 91 | |
| 76 | 92 | describe("shoulderTabReserve", () => { |
| 77 | 93 | it("ignores the top drawer when measuring the shoulder tab band", () => { |
apps/web/src/components/ChatView.logic.ts+13| ... | ... | @@ -12,6 +12,11 @@ import { |
| 12 | 12 | type ThreadId, |
| 13 | 13 | type TurnId, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | import { | |
| 16 | appendCodexArtifactTemplateUsePrompt, | |
| 17 | codexArtifactTemplateUsePrompt, | |
| 18 | type CodexArtifactTemplate, | |
| 19 | } from "@t3tools/client-runtime/codex-artifact-templates"; | |
| 15 | 20 | import { |
| 16 | 21 | type ChatMessage, |
| 17 | 22 | isImageAttachment, |
| ... | ... | @@ -39,6 +44,14 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; |
| 39 | 44 | |
| 40 | 45 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); |
| 41 | 46 | |
| 47 | export function codexArtifactTemplatePromptToAppend( | |
| 48 | currentDraft: string, | |
| 49 | template: CodexArtifactTemplate, | |
| 50 | ): string | null { | |
| 51 | return appendCodexArtifactTemplateUsePrompt(currentDraft, template) === currentDraft | |
| 52 | ? null | |
| 53 | : codexArtifactTemplateUsePrompt(template); | |
| 54 | } | |
| 42 | 55 | export function shoulderTabReserve(overlay: HTMLElement): number { |
| 43 | 56 | if (overlay.querySelector(".chat-composer-tasks-tab")) return 0; |
| 44 | 57 | const tab = overlay.querySelector<HTMLElement>(".chat-composer-shoulder-tab"); |
apps/web/src/components/ChatView.tsx+22| ... | ... | @@ -29,6 +29,7 @@ import { |
| 29 | 29 | type EnvironmentConnectionPresentation, |
| 30 | 30 | } from "@t3tools/client-runtime/connection"; |
| 31 | 31 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| 32 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | |
| 32 | 33 | import { |
| 33 | 34 | changeRequestAutoSettles, |
| 34 | 35 | effectiveSettled, |
| ... | ... | @@ -376,6 +377,7 @@ import { |
| 376 | 377 | revokeUserMessagePreviewUrls, |
| 377 | 378 | shouldWriteThreadErrorToCurrentServerThread, |
| 378 | 379 | startNewThreadForProject, |
| 380 | codexArtifactTemplatePromptToAppend, | |
| 379 | 381 | waitForStartedServerThread, |
| 380 | 382 | } from "./ChatView.logic"; |
| 381 | 383 | import type { ThreadSyncPhase } from "../threadSync"; |
| ... | ... | @@ -3126,6 +3128,25 @@ function ChatViewContent(props: ChatViewProps) { |
| 3126 | 3128 | focusComposer(); |
| 3127 | 3129 | }); |
| 3128 | 3130 | }, [focusComposer]); |
| 3131 | const useArtifactTemplate = useCallback( | |
| 3132 | (template: CodexArtifactTemplate) => { | |
| 3133 | const composer = composerRef.current; | |
| 3134 | if (!composer) return; | |
| 3135 | ||
| 3136 | const currentDraft = composer.getSendContext().prompt; | |
| 3137 | const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template); | |
| 3138 | if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) { | |
| 3139 | toastManager.add({ | |
| 3140 | type: "error", | |
| 3141 | title: "Unable to add to chat", | |
| 3142 | description: "The composer is busy; try again once it is ready.", | |
| 3143 | }); | |
| 3144 | return; | |
| 3145 | } | |
| 3146 | scheduleComposerFocus(); | |
| 3147 | }, | |
| 3148 | [composerRef, scheduleComposerFocus], | |
| 3149 | ); | |
| 3129 | 3150 | const addTerminalContextToDraft = useCallback( |
| 3130 | 3151 | (selection: TerminalContextSelection) => { |
| 3131 | 3152 | composerRef.current?.addTerminalContext(selection); |
| ... | ... | @@ -7237,6 +7258,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7237 | 7258 | onOpenTurnDiff={onOpenTurnDiff} |
| 7238 | 7259 | revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} |
| 7239 | 7260 | onRevertUserMessage={onRevertUserMessage} |
| 7261 | onUseArtifactTemplate={useArtifactTemplate} | |
| 7240 | 7262 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 7241 | 7263 | onImageExpand={onExpandTimelineImage} |
| 7242 | 7264 | onFileOpen={openFileAttachment} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+17| ... | ... | @@ -270,6 +270,23 @@ describe("resolveAssistantMessageCopyState", () => { |
| 270 | 270 | visible: false, |
| 271 | 271 | }); |
| 272 | 272 | }); |
| 273 | ||
| 274 | it("copies the rendered representation of Codex directives", () => { | |
| 275 | expect( | |
| 276 | resolveAssistantMessageCopyState({ | |
| 277 | showCopyButton: true, | |
| 278 | text: [ | |
| 279 | 'Created :codex-file-citation{path="outputs/report.xlsx" purpose="output"}.', | |
| 280 | "", | |
| 281 | '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}', | |
| 282 | ].join("\n"), | |
| 283 | streaming: false, | |
| 284 | }), | |
| 285 | ).toEqual({ | |
| 286 | text: "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)", | |
| 287 | visible: true, | |
| 288 | }); | |
| 289 | }); | |
| 273 | 290 | }); |
| 274 | 291 | |
| 275 | 292 | describe("deriveMessagesTimelineRows", () => { |
apps/web/src/components/chat/MessagesTimeline.logic.ts+4-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; | |
| 2 | 3 | import { |
| 3 | 4 | formatDuration, |
| 4 | 5 | workEntryDisplayIndicatesToolFailure, |
| ... | ... | @@ -439,9 +440,10 @@ export function resolveAssistantMessageCopyState({ |
| 439 | 440 | streaming: boolean; |
| 440 | 441 | }) { |
| 441 | 442 | const hasText = text !== null && text.trim().length > 0; |
| 443 | const visible = showCopyButton && hasText && !streaming; | |
| 442 | 444 | return { |
| 443 | text: hasText ? text : null, | |
| 444 | visible: showCopyButton && hasText && !streaming, | |
| 445 | text: hasText ? (visible ? renderCodexDirectivesForCopy(text) : text) : null, | |
| 446 | visible, | |
| 445 | 447 | }; |
| 446 | 448 | } |
| 447 | 449 |
apps/web/src/components/chat/MessagesTimeline.tsx+8| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | type TurnId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | |
| 10 | 11 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 11 | 12 | import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 12 | 13 | import { |
| ... | ... | @@ -16,6 +17,7 @@ import { |
| 16 | 17 | |
| 17 | 18 | const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); |
| 18 | 19 | const NOOP_OPEN_AGENTS = () => {}; |
| 20 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; | |
| 19 | 21 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 20 | 22 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 21 | 23 | import { |
| ... | ... | @@ -154,6 +156,7 @@ interface TimelineRowSharedState { |
| 154 | 156 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 155 | 157 | activeThreadEnvironmentId: EnvironmentId; |
| 156 | 158 | onRevertUserMessage: (messageId: MessageId) => void; |
| 159 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; | |
| 157 | 160 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 158 | 161 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| 159 | 162 | openingVideoAttachmentId: string | null; |
| ... | ... | @@ -235,6 +238,7 @@ interface MessagesTimelineProps { |
| 235 | 238 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 236 | 239 | revertTurnCountByUserMessageId: Map<MessageId, number>; |
| 237 | 240 | onRevertUserMessage: (messageId: MessageId) => void; |
| 241 | onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; | |
| 238 | 242 | isRevertingCheckpoint: boolean; |
| 239 | 243 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 240 | 244 | onFileOpen?: (attachment: ChatFileAttachment) => void; |
| ... | ... | @@ -282,6 +286,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 282 | 286 | onOpenTurnDiff, |
| 283 | 287 | revertTurnCountByUserMessageId, |
| 284 | 288 | onRevertUserMessage, |
| 289 | onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, | |
| 285 | 290 | isRevertingCheckpoint, |
| 286 | 291 | onImageExpand, |
| 287 | 292 | onFileOpen = NOOP_OPEN_ATTACHMENT, |
| ... | ... | @@ -543,6 +548,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 543 | 548 | skills, |
| 544 | 549 | activeThreadEnvironmentId, |
| 545 | 550 | onRevertUserMessage, |
| 551 | onUseArtifactTemplate, | |
| 546 | 552 | onImageExpand, |
| 547 | 553 | onFileOpen, |
| 548 | 554 | openingVideoAttachmentId, |
| ... | ... | @@ -561,6 +567,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 561 | 567 | skills, |
| 562 | 568 | activeThreadEnvironmentId, |
| 563 | 569 | onRevertUserMessage, |
| 570 | onUseArtifactTemplate, | |
| 564 | 571 | onImageExpand, |
| 565 | 572 | onFileOpen, |
| 566 | 573 | openingVideoAttachmentId, |
| ... | ... | @@ -1253,6 +1260,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 1253 | 1260 | isStreaming={Boolean(row.message.streaming)} |
| 1254 | 1261 | lineBreaks={shouldPreserveAssistantLineBreaks(messageText)} |
| 1255 | 1262 | skills={ctx.skills} |
| 1263 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} | |
| 1256 | 1264 | onImageExpand={ctx.onImageExpand} |
| 1257 | 1265 | /> |
| 1258 | 1266 | <AssistantChangedFilesSection |
apps/web/src/markdown-clipboard.test.ts+20-4| ... | ... | @@ -22,6 +22,7 @@ class FakeElement { |
| 22 | 22 | constructor( |
| 23 | 23 | readonly tagName: string, |
| 24 | 24 | private readonly classNames: ReadonlyArray<string> = [], |
| 25 | private readonly attributes: Readonly<Record<string, string>> = {}, | |
| 25 | 26 | ) {} |
| 26 | 27 | |
| 27 | 28 | get localName(): string { |
| ... | ... | @@ -37,12 +38,12 @@ class FakeElement { |
| 37 | 38 | return this; |
| 38 | 39 | } |
| 39 | 40 | |
| 40 | getAttribute(): string | null { | |
| 41 | return null; | |
| 41 | getAttribute(name: string): string | null { | |
| 42 | return this.attributes[name] ?? null; | |
| 42 | 43 | } |
| 43 | 44 | |
| 44 | hasAttribute(): boolean { | |
| 45 | return false; | |
| 45 | hasAttribute(name: string): boolean { | |
| 46 | return Object.hasOwn(this.attributes, name); | |
| 46 | 47 | } |
| 47 | 48 | } |
| 48 | 49 | |
| ... | ... | @@ -92,4 +93,19 @@ describe("serializeRenderedMarkdownFragment", () => { |
| 92 | 93 | |
| 93 | 94 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); |
| 94 | 95 | }); |
| 96 | ||
| 97 | it("uses a rendered card's explicit Markdown copy representation", () => { | |
| 98 | const card = new FakeElement("DIV", [], { | |
| 99 | "data-markdown-copy": "Hello World (Document template)\n\n", | |
| 100 | }).append( | |
| 101 | new FakeElement("SPAN").append(new FakeText("Hello World")), | |
| 102 | new FakeElement("SPAN").append(new FakeText("Document template")), | |
| 103 | new FakeElement("BUTTON").append(new FakeText("Use template")), | |
| 104 | ); | |
| 105 | const container = new FakeElement("DIV").append(card); | |
| 106 | ||
| 107 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( | |
| 108 | "Hello World (Document template)", | |
| 109 | ); | |
| 110 | }); | |
| 95 | 111 | }); |
packages/client-runtime/package.json+19-1| ... | ... | @@ -19,6 +19,18 @@ |
| 19 | 19 | "types": "./src/markdownImages.ts", |
| 20 | 20 | "default": "./src/markdownImages.ts" |
| 21 | 21 | }, |
| 22 | "./codex-file-citations": { | |
| 23 | "types": "./src/codexFileCitations.ts", | |
| 24 | "default": "./src/codexFileCitations.ts" | |
| 25 | }, | |
| 26 | "./codex-artifact-templates": { | |
| 27 | "types": "./src/codexArtifactTemplates.ts", | |
| 28 | "default": "./src/codexArtifactTemplates.ts" | |
| 29 | }, | |
| 30 | "./codex-markdown-directives": { | |
| 31 | "types": "./src/codexMarkdownDirectives.ts", | |
| 32 | "default": "./src/codexMarkdownDirectives.ts" | |
| 33 | }, | |
| 22 | 34 | "./errors": { |
| 23 | 35 | "types": "./src/errors/index.ts", |
| 24 | 36 | "default": "./src/errors/index.ts" |
| ... | ... | @@ -175,10 +187,16 @@ |
| 175 | 187 | "dependencies": { |
| 176 | 188 | "@t3tools/contracts": "workspace:*", |
| 177 | 189 | "@t3tools/shared": "workspace:*", |
| 178 | "effect": "catalog:" | |
| 190 | "effect": "catalog:", | |
| 191 | "mdast-util-directive": "^3.1.0", | |
| 192 | "micromark-extension-directive": "^4.0.0", | |
| 193 | "micromark-util-character": "^2.1.1", | |
| 194 | "remark-parse": "^11.0.0", | |
| 195 | "unified": "^11.0.5" | |
| 179 | 196 | }, |
| 180 | 197 | "devDependencies": { |
| 181 | 198 | "@effect/vitest": "catalog:", |
| 199 | "micromark-util-types": "^2.0.2", | |
| 182 | 200 | "vite-plus": "catalog:" |
| 183 | 201 | } |
| 184 | 202 | } |
packages/client-runtime/src/codexArtifactTemplates.test.ts created+119| ... | ... | @@ -0,0 +1,119 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | appendCodexArtifactTemplateUsePrompt, | |
| 5 | codexArtifactTemplatePresentationLabel, | |
| 6 | codexArtifactTemplateUsePrompt, | |
| 7 | resolveCodexArtifactTemplate, | |
| 8 | type CodexArtifactTemplate, | |
| 9 | } from "./codexArtifactTemplates.js"; | |
| 10 | ||
| 11 | const HELLO_WORLD_TEMPLATE: CodexArtifactTemplate = { | |
| 12 | artifactKind: "document", | |
| 13 | displayName: "Hello World", | |
| 14 | skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", | |
| 15 | skillName: "artifact-template-hello-world", | |
| 16 | }; | |
| 17 | ||
| 18 | describe("artifact template presentation", () => { | |
| 19 | it("shares labels and copy text across clients", () => { | |
| 20 | expect(codexArtifactTemplatePresentationLabel("document")).toBe("Document template"); | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | describe("resolveCodexArtifactTemplate", () => { | |
| 25 | it("accepts the template metadata emitted by Codex", () => { | |
| 26 | expect( | |
| 27 | resolveCodexArtifactTemplate({ | |
| 28 | artifact_kind: "document", | |
| 29 | display_name: " Hello World ", | |
| 30 | skill_directory: "/Users/test/.codex/skills/artifact-template-hello-world", | |
| 31 | skill_name: "artifact-template-hello-world", | |
| 32 | }), | |
| 33 | ).toEqual(HELLO_WORLD_TEMPLATE); | |
| 34 | }); | |
| 35 | ||
| 36 | it.each([ | |
| 37 | String.raw`C:\Users\test\.codex\skills\artifact-template-hello-world`, | |
| 38 | String.raw`\\server\share\artifact-template-hello-world`, | |
| 39 | "//server/share/artifact-template-hello-world", | |
| 40 | ])("accepts absolute Windows skill directories: %s", (skillDirectory) => { | |
| 41 | expect( | |
| 42 | resolveCodexArtifactTemplate({ | |
| 43 | artifact_kind: "image", | |
| 44 | display_name: "Reference image", | |
| 45 | gallery_kind: "product-design", | |
| 46 | skill_directory: skillDirectory, | |
| 47 | skill_name: "artifact-template-reference-image", | |
| 48 | }), | |
| 49 | ).toMatchObject({ | |
| 50 | artifactKind: "image", | |
| 51 | galleryKind: "product-design", | |
| 52 | skillDirectory, | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | it.each([ | |
| 57 | { artifact_kind: "unknown" }, | |
| 58 | { display_name: " " }, | |
| 59 | { skill_directory: "relative/template" }, | |
| 60 | { skill_name: "hello-world" }, | |
| 61 | { gallery_kind: null }, | |
| 62 | { gallery_kind: "unknown" }, | |
| 63 | ])("rejects malformed template metadata: %o", (override) => { | |
| 64 | expect( | |
| 65 | resolveCodexArtifactTemplate({ | |
| 66 | artifact_kind: "document", | |
| 67 | display_name: "Hello World", | |
| 68 | skill_directory: "/templates/hello-world", | |
| 69 | skill_name: "artifact-template-hello-world", | |
| 70 | ...override, | |
| 71 | }), | |
| 72 | ).toBeNull(); | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | describe("codexArtifactTemplateUsePrompt", () => { | |
| 77 | it("builds the same document follow-up shape as Codex", () => { | |
| 78 | expect(codexArtifactTemplateUsePrompt(HELLO_WORLD_TEMPLATE)).toBe( | |
| 79 | "Create a document using this $artifact-template-hello-world about…", | |
| 80 | ); | |
| 81 | }); | |
| 82 | ||
| 83 | it("uses the artifact-specific image wording", () => { | |
| 84 | expect( | |
| 85 | codexArtifactTemplateUsePrompt({ | |
| 86 | ...HELLO_WORLD_TEMPLATE, | |
| 87 | artifactKind: "image", | |
| 88 | }), | |
| 89 | ).toBe("Create an image using this $artifact-template-hello-world of…"); | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | describe("appendCodexArtifactTemplateUsePrompt", () => { | |
| 94 | const prompt = "Create a document using this $artifact-template-hello-world about…"; | |
| 95 | ||
| 96 | it("adds the prompt to an empty draft", () => { | |
| 97 | expect(appendCodexArtifactTemplateUsePrompt("", HELLO_WORLD_TEMPLATE)).toBe(prompt); | |
| 98 | }); | |
| 99 | ||
| 100 | it("preserves existing draft text", () => { | |
| 101 | expect(appendCodexArtifactTemplateUsePrompt("Write about otters", HELLO_WORLD_TEMPLATE)).toBe( | |
| 102 | `Write about otters ${prompt}`, | |
| 103 | ); | |
| 104 | }); | |
| 105 | ||
| 106 | it.each([prompt, `${prompt}\n`, `Notes\n\n${prompt}`])( | |
| 107 | "does not append the same final prompt twice: %s", | |
| 108 | (draft) => { | |
| 109 | expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(draft); | |
| 110 | }, | |
| 111 | ); | |
| 112 | ||
| 113 | it("does not mistake text containing the prompt for a final prompt", () => { | |
| 114 | const draft = `${prompt}\nAdditional instructions`; | |
| 115 | expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe( | |
| 116 | `${draft} ${prompt}`, | |
| 117 | ); | |
| 118 | }); | |
| 119 | }); |
packages/client-runtime/src/codexArtifactTemplates.ts created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | export const CODEX_ARTIFACT_TEMPLATE_KINDS = [ | |
| 2 | "document", | |
| 3 | "presentation", | |
| 4 | "spreadsheet", | |
| 5 | "site", | |
| 6 | "google-docs", | |
| 7 | "google-slides", | |
| 8 | "google-sheets", | |
| 9 | "image", | |
| 10 | "email", | |
| 11 | "slack", | |
| 12 | ] as const; | |
| 13 | ||
| 14 | export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number]; | |
| 15 | ||
| 16 | export const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; | |
| 17 | ||
| 18 | export type CodexArtifactTemplateGalleryKind = | |
| 19 | (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number]; | |
| 20 | ||
| 21 | export interface CodexArtifactTemplate { | |
| 22 | readonly artifactKind: CodexArtifactTemplateKind; | |
| 23 | readonly displayName: string; | |
| 24 | readonly galleryKind?: CodexArtifactTemplateGalleryKind; | |
| 25 | readonly skillDirectory: string; | |
| 26 | readonly skillName: string; | |
| 27 | } | |
| 28 | ||
| 29 | export const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = { | |
| 30 | document: "Document template", | |
| 31 | presentation: "Presentation template", | |
| 32 | spreadsheet: "Spreadsheet template", | |
| 33 | site: "Site template", | |
| 34 | "google-docs": "Google Doc template", | |
| 35 | "google-slides": "Google Slides template", | |
| 36 | "google-sheets": "Google Sheet template", | |
| 37 | image: "Image template", | |
| 38 | email: "Email template", | |
| 39 | slack: "Slack template", | |
| 40 | } satisfies Record<CodexArtifactTemplateKind, string>; | |
| 41 | ||
| 42 | export type CodexArtifactTemplateAttributes = Readonly<Record<string, string | null | undefined>>; | |
| 43 | ||
| 44 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | |
| 45 | const WINDOWS_UNC_PATH_REGEX = /^(?:\\\\[^\\]+\\[^\\]+|\/\/[^/]+\/[^/]+)/; | |
| 46 | ||
| 47 | function isCodexArtifactTemplateKind(value: unknown): value is CodexArtifactTemplateKind { | |
| 48 | return CODEX_ARTIFACT_TEMPLATE_KINDS.some((kind) => kind === value); | |
| 49 | } | |
| 50 | ||
| 51 | function isCodexArtifactTemplateGalleryKind( | |
| 52 | value: unknown, | |
| 53 | ): value is CodexArtifactTemplateGalleryKind { | |
| 54 | return CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS.some((kind) => kind === value); | |
| 55 | } | |
| 56 | ||
| 57 | function isAbsoluteSkillDirectory(value: string): boolean { | |
| 58 | return ( | |
| 59 | (value.startsWith("/") && !value.startsWith("//")) || | |
| 60 | WINDOWS_DRIVE_PATH_REGEX.test(value) || | |
| 61 | WINDOWS_UNC_PATH_REGEX.test(value) | |
| 62 | ); | |
| 63 | } | |
| 64 | ||
| 65 | /** Mirrors the Codex result-card schema so malformed directives remain literal Markdown. */ | |
| 66 | export function resolveCodexArtifactTemplate( | |
| 67 | attributes: CodexArtifactTemplateAttributes | null | undefined, | |
| 68 | ): CodexArtifactTemplate | null { | |
| 69 | const artifactKind = attributes?.artifact_kind; | |
| 70 | const displayNameValue = attributes?.display_name; | |
| 71 | const displayName = typeof displayNameValue === "string" ? displayNameValue.trim() : undefined; | |
| 72 | const galleryKind = attributes?.gallery_kind; | |
| 73 | const skillDirectory = attributes?.skill_directory; | |
| 74 | const skillName = attributes?.skill_name; | |
| 75 | ||
| 76 | if ( | |
| 77 | !isCodexArtifactTemplateKind(artifactKind) || | |
| 78 | !displayName || | |
| 79 | typeof skillDirectory !== "string" || | |
| 80 | !isAbsoluteSkillDirectory(skillDirectory) || | |
| 81 | typeof skillName !== "string" || | |
| 82 | !skillName.startsWith("artifact-template-") || | |
| 83 | (galleryKind !== undefined && !isCodexArtifactTemplateGalleryKind(galleryKind)) | |
| 84 | ) { | |
| 85 | return null; | |
| 86 | } | |
| 87 | ||
| 88 | return { | |
| 89 | artifactKind, | |
| 90 | displayName, | |
| 91 | ...(galleryKind === undefined ? {} : { galleryKind }), | |
| 92 | skillDirectory, | |
| 93 | skillName, | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| 97 | const USE_PROMPT_BY_KIND: Record<CodexArtifactTemplateKind, (skill: string) => string> = { | |
| 98 | document: (skill) => `Create a document using this ${skill} about…`, | |
| 99 | presentation: (skill) => `Create a presentation using the ${skill} template about…`, | |
| 100 | spreadsheet: (skill) => `Create a spreadsheet using this ${skill} about…`, | |
| 101 | site: (skill) => `Create a Site using this ${skill} about…`, | |
| 102 | "google-docs": (skill) => `Create a Google Doc using this ${skill} about…`, | |
| 103 | "google-slides": (skill) => `Create a Google Slides presentation using this ${skill} about…`, | |
| 104 | "google-sheets": (skill) => `Create a Google Sheet using this ${skill} about…`, | |
| 105 | image: (skill) => `Create an image using this ${skill} of…`, | |
| 106 | email: (skill) => `Draft an email using this ${skill} about…`, | |
| 107 | slack: (skill) => `Draft a Slack message using this ${skill} about…`, | |
| 108 | }; | |
| 109 | ||
| 110 | export function codexArtifactTemplateUsePrompt(template: CodexArtifactTemplate): string { | |
| 111 | return USE_PROMPT_BY_KIND[template.artifactKind](`$${template.skillName}`); | |
| 112 | } | |
| 113 | ||
| 114 | export function codexArtifactTemplatePresentationLabel(kind: CodexArtifactTemplateKind): string { | |
| 115 | return CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND[kind]; | |
| 116 | } | |
| 117 | ||
| 118 | export function appendCodexArtifactTemplateUsePrompt( | |
| 119 | draft: string, | |
| 120 | template: CodexArtifactTemplate, | |
| 121 | ): string { | |
| 122 | const prompt = codexArtifactTemplateUsePrompt(template); | |
| 123 | const trimmedDraft = draft.trimEnd(); | |
| 124 | const promptStart = trimmedDraft.length - prompt.length; | |
| 125 | const alreadyEndsWithPrompt = | |
| 126 | promptStart >= 0 && | |
| 127 | trimmedDraft.slice(promptStart) === prompt && | |
| 128 | (promptStart === 0 || /\s/.test(trimmedDraft[promptStart - 1] ?? "")); | |
| 129 | ||
| 130 | if (alreadyEndsWithPrompt) { | |
| 131 | return draft; | |
| 132 | } | |
| 133 | ||
| 134 | const needsLeadingSpace = draft.length > 0 && !/\s$/.test(draft); | |
| 135 | return `${draft}${needsLeadingSpace ? " " : ""}${prompt}`; | |
| 136 | } |
packages/client-runtime/src/codexFileCitations.test.ts created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.js"; | |
| 4 | ||
| 5 | describe("resolveCodexFileCitationLink", () => { | |
| 6 | it("resolves the attributes emitted by Codex", () => { | |
| 7 | expect( | |
| 8 | resolveCodexFileCitationLink({ | |
| 9 | path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx", | |
| 10 | purpose: "output", | |
| 11 | }), | |
| 12 | ).toEqual({ | |
| 13 | path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx", | |
| 14 | href: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx", | |
| 15 | label: "issue-2387-sparse-diagonal.xlsx", | |
| 16 | }); | |
| 17 | }); | |
| 18 | ||
| 19 | it("carries the first cited line into the file href", () => { | |
| 20 | expect( | |
| 21 | resolveCodexFileCitationLink({ | |
| 22 | path: "src/main.ts", | |
| 23 | line_range_start: "42", | |
| 24 | line_range_end: "48", | |
| 25 | git_url: "https://example.com/main.ts", | |
| 26 | }), | |
| 27 | ).toEqual({ | |
| 28 | path: "src/main.ts", | |
| 29 | href: "src/main.ts#L42", | |
| 30 | label: "main.ts", | |
| 31 | lineRangeStart: 42, | |
| 32 | }); | |
| 33 | }); | |
| 34 | ||
| 35 | it("rejects missing paths and invalid line numbers", () => { | |
| 36 | expect(resolveCodexFileCitationLink({ purpose: "output" })).toBeNull(); | |
| 37 | expect( | |
| 38 | resolveCodexFileCitationLink({ path: "src/main.ts", line_range_start: "not-a-line" }), | |
| 39 | ).toEqual({ | |
| 40 | path: "src/main.ts", | |
| 41 | href: "src/main.ts", | |
| 42 | label: "main.ts", | |
| 43 | }); | |
| 44 | }); | |
| 45 | ||
| 46 | it("preserves URL syntax characters in file paths", () => { | |
| 47 | expect( | |
| 48 | resolveCodexFileCitationLink({ | |
| 49 | path: "reports/100% #1? draft.md", | |
| 50 | line_range_start: "7", | |
| 51 | }), | |
| 52 | ).toEqual({ | |
| 53 | path: "reports/100% #1? draft.md", | |
| 54 | href: "reports/100%25 %231%3F draft.md#L7", | |
| 55 | label: "100% #1? draft.md", | |
| 56 | lineRangeStart: 7, | |
| 57 | }); | |
| 58 | }); | |
| 59 | }); | |
| 60 | ||
| 61 | describe("codexFileCitationMarkdown", () => { | |
| 62 | it("produces a portable Markdown link", () => { | |
| 63 | const citation = resolveCodexFileCitationLink({ path: "reports/profit and loss.xlsx" }); | |
| 64 | expect(citation && codexFileCitationMarkdown(citation)).toBe( | |
| 65 | "[profit and loss.xlsx](<reports/profit and loss.xlsx>)", | |
| 66 | ); | |
| 67 | }); | |
| 68 | ||
| 69 | it("escapes Markdown syntax in the visible filename", () => { | |
| 70 | const citation = resolveCodexFileCitationLink({ | |
| 71 | path: "reports/*draft*_[copy]`<&.txt", | |
| 72 | }); | |
| 73 | expect(citation && codexFileCitationMarkdown(citation)).toBe( | |
| 74 | "[\\*draft\\*\\_\\[copy\\]\\`\\<\\&.txt](<reports/*draft*_[copy]`%3C&.txt>)", | |
| 75 | ); | |
| 76 | }); | |
| 77 | }); |
packages/client-runtime/src/codexFileCitations.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | export interface CodexFileCitationLink { | |
| 2 | readonly path: string; | |
| 3 | readonly href: string; | |
| 4 | readonly label: string; | |
| 5 | readonly lineRangeStart?: number; | |
| 6 | } | |
| 7 | ||
| 8 | export type CodexFileCitationAttributes = Readonly<Record<string, string | null | undefined>>; | |
| 9 | ||
| 10 | function positiveInteger(value: string | null | undefined): number | undefined { | |
| 11 | if (value === null || value === undefined || value.trim().length === 0) return undefined; | |
| 12 | const parsed = Number(value); | |
| 13 | return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; | |
| 14 | } | |
| 15 | ||
| 16 | function citationLabel(path: string): string { | |
| 17 | const normalized = path.replaceAll("\\", "/").replace(/\/+$/, ""); | |
| 18 | return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized || "File"; | |
| 19 | } | |
| 20 | ||
| 21 | function markdownDestinationPath(path: string): string { | |
| 22 | return path.replaceAll("%", "%25").replaceAll("#", "%23").replaceAll("?", "%3F"); | |
| 23 | } | |
| 24 | ||
| 25 | export function resolveCodexFileCitationLink( | |
| 26 | attributes: CodexFileCitationAttributes | null | undefined, | |
| 27 | ): CodexFileCitationLink | null { | |
| 28 | const path = attributes?.path?.trim(); | |
| 29 | if (!path) return null; | |
| 30 | ||
| 31 | const lineRangeStart = positiveInteger(attributes?.line_range_start); | |
| 32 | const destinationPath = markdownDestinationPath(path); | |
| 33 | return { | |
| 34 | path, | |
| 35 | href: lineRangeStart === undefined ? destinationPath : `${destinationPath}#L${lineRangeStart}`, | |
| 36 | label: citationLabel(path), | |
| 37 | ...(lineRangeStart === undefined ? {} : { lineRangeStart }), | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | function markdownLabel(value: string): string { | |
| 42 | return value.replace(/[\\[\]*_`<&]/g, "\\$&"); | |
| 43 | } | |
| 44 | ||
| 45 | function markdownDestination(value: string): string { | |
| 46 | return value | |
| 47 | .replaceAll("<", "%3C") | |
| 48 | .replaceAll(">", "%3E") | |
| 49 | .replaceAll("\r", "%0D") | |
| 50 | .replaceAll("\n", "%0A"); | |
| 51 | } | |
| 52 | ||
| 53 | export function codexFileCitationMarkdown(citation: CodexFileCitationLink): string { | |
| 54 | return `[${markdownLabel(citation.label)}](<${markdownDestination(citation.href)}>)`; | |
| 55 | } |
packages/client-runtime/src/codexMarkdownDirectives.test.ts created+156| ... | ... | @@ -0,0 +1,156 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import remarkParse from "remark-parse"; | |
| 3 | import { unified } from "unified"; | |
| 4 | ||
| 5 | import { | |
| 6 | remarkCodexDirectives, | |
| 7 | renderCodexDirectivesForCopy, | |
| 8 | renderCodexFileCitationsAsMarkdown, | |
| 9 | splitCodexArtifactTemplateMarkdown, | |
| 10 | } from "./codexMarkdownDirectives.js"; | |
| 11 | ||
| 12 | interface TestNode { | |
| 13 | readonly type: string; | |
| 14 | readonly value?: string; | |
| 15 | readonly url?: string; | |
| 16 | readonly position?: { | |
| 17 | readonly start: { readonly offset?: number }; | |
| 18 | readonly end: { readonly offset?: number }; | |
| 19 | }; | |
| 20 | readonly data?: { | |
| 21 | readonly hName?: string; | |
| 22 | readonly hProperties?: Readonly<Record<string, unknown>>; | |
| 23 | }; | |
| 24 | readonly children?: readonly TestNode[]; | |
| 25 | } | |
| 26 | ||
| 27 | const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}'; | |
| 28 | const ARTIFACT_TEMPLATE = | |
| 29 | '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; | |
| 30 | ||
| 31 | function parse(markdown: string): TestNode { | |
| 32 | const processor = unified().use(remarkParse).use(remarkCodexDirectives); | |
| 33 | return processor.runSync(processor.parse(markdown), { value: markdown }) as TestNode; | |
| 34 | } | |
| 35 | ||
| 36 | function parseOrdinaryMarkdown(markdown: string): TestNode { | |
| 37 | return unified().use(remarkParse).parse(markdown) as TestNode; | |
| 38 | } | |
| 39 | ||
| 40 | describe("remarkCodexDirectives", () => { | |
| 41 | it("renders a file citation as a link without changing its source position", () => { | |
| 42 | const markdown = `Created ${FILE_CITATION}.`; | |
| 43 | const link = parse(markdown).children?.[0]?.children?.[1]; | |
| 44 | ||
| 45 | expect(link).toMatchObject({ | |
| 46 | type: "link", | |
| 47 | url: "outputs/report.xlsx", | |
| 48 | children: [{ type: "text", value: "report.xlsx" }], | |
| 49 | position: { | |
| 50 | start: { offset: markdown.indexOf(FILE_CITATION) }, | |
| 51 | end: { offset: markdown.indexOf(FILE_CITATION) + FILE_CITATION.length }, | |
| 52 | }, | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | it("renders an artifact template as semantic block metadata", () => { | |
| 57 | expect(parse(ARTIFACT_TEMPLATE).children?.[0]).toMatchObject({ | |
| 58 | type: "paragraph", | |
| 59 | children: [], | |
| 60 | data: { | |
| 61 | hName: "div", | |
| 62 | hProperties: { | |
| 63 | dataCodexArtifactTemplate: "true", | |
| 64 | dataArtifactKind: "document", | |
| 65 | dataDisplayName: "Hello World", | |
| 66 | dataSkillName: "artifact-template-hello-world", | |
| 67 | }, | |
| 68 | }, | |
| 69 | }); | |
| 70 | }); | |
| 71 | ||
| 72 | it.each([ | |
| 73 | "Meeting at 10:30", | |
| 74 | "Open src/main.ts:42", | |
| 75 | "Use :hover and :tada:", | |
| 76 | "::note", | |
| 77 | ":::note\ncontent\n:::", | |
| 78 | ':codex-file-citation-extra{path="outputs/report.xlsx"}', | |
| 79 | "::artifact-template-extra", | |
| 80 | ])("does not change unrelated colon syntax: %s", (markdown) => { | |
| 81 | expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); | |
| 82 | }); | |
| 83 | ||
| 84 | it.each([ | |
| 85 | ':codex-file-citation{purpose="output"}', | |
| 86 | '::artifact-template{skill_name="artifact-template-hello-world"}', | |
| 87 | ])("keeps malformed supported directives literal: %s", (markdown) => { | |
| 88 | expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | describe("native Markdown adapters", () => { | |
| 93 | it("uses the same parser to render file citations as portable links", () => { | |
| 94 | expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe( | |
| 95 | "Created [report.xlsx](<outputs/report.xlsx>).", | |
| 96 | ); | |
| 97 | }); | |
| 98 | ||
| 99 | it.each([ | |
| 100 | `\\${FILE_CITATION}`, | |
| 101 | `\`${FILE_CITATION}\``, | |
| 102 | `\`\`\`text\n${FILE_CITATION}\n\`\`\``, | |
| 103 | `[See ${FILE_CITATION}](https://example.com)`, | |
| 104 | ])("does not render excluded citation syntax: %s", (markdown) => { | |
| 105 | expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown); | |
| 106 | }); | |
| 107 | ||
| 108 | it("splits artifact cards from surrounding native Markdown", () => { | |
| 109 | expect(splitCodexArtifactTemplateMarkdown(`Before\n\n${ARTIFACT_TEMPLATE}\n\nAfter`)).toEqual([ | |
| 110 | { kind: "markdown", markdown: "Before\n\n", sourceOffset: 0 }, | |
| 111 | { | |
| 112 | kind: "artifact-template", | |
| 113 | sourceOffset: 8, | |
| 114 | template: { | |
| 115 | artifactKind: "document", | |
| 116 | displayName: "Hello World", | |
| 117 | skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", | |
| 118 | skillName: "artifact-template-hello-world", | |
| 119 | }, | |
| 120 | }, | |
| 121 | { | |
| 122 | kind: "markdown", | |
| 123 | markdown: "\n\nAfter", | |
| 124 | sourceOffset: 8 + ARTIFACT_TEMPLATE.length, | |
| 125 | }, | |
| 126 | ]); | |
| 127 | }); | |
| 128 | ||
| 129 | it("leaves malformed and code artifact-template examples in Markdown", () => { | |
| 130 | const malformed = '::artifact-template{display_name="Hello World"}'; | |
| 131 | const code = `\`${ARTIFACT_TEMPLATE}\``; | |
| 132 | expect(splitCodexArtifactTemplateMarkdown(malformed)).toEqual([ | |
| 133 | { kind: "markdown", markdown: malformed, sourceOffset: 0 }, | |
| 134 | ]); | |
| 135 | expect(splitCodexArtifactTemplateMarkdown(code)).toEqual([ | |
| 136 | { kind: "markdown", markdown: code, sourceOffset: 0 }, | |
| 137 | ]); | |
| 138 | }); | |
| 139 | }); | |
| 140 | ||
| 141 | describe("directive copy adapter", () => { | |
| 142 | it("copies the Markdown representations shown by citation chips and template cards", () => { | |
| 143 | expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe( | |
| 144 | "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)", | |
| 145 | ); | |
| 146 | }); | |
| 147 | ||
| 148 | it("leaves excluded and malformed directive source unchanged", () => { | |
| 149 | const markdown = [ | |
| 150 | `\`${FILE_CITATION}\``, | |
| 151 | '::artifact-template{display_name="Hello World"}', | |
| 152 | ].join("\n\n"); | |
| 153 | ||
| 154 | expect(renderCodexDirectivesForCopy(markdown)).toBe(markdown); | |
| 155 | }); | |
| 156 | }); |
packages/client-runtime/src/codexMarkdownDirectives.ts created+388| ... | ... | @@ -0,0 +1,388 @@ |
| 1 | import { directiveFromMarkdown } from "mdast-util-directive"; | |
| 2 | import { directive } from "micromark-extension-directive"; | |
| 3 | import { | |
| 4 | markdownLineEnding, | |
| 5 | unicodePunctuation, | |
| 6 | unicodeWhitespace, | |
| 7 | } from "micromark-util-character"; | |
| 8 | import type { Construct, Extension, Tokenizer } from "micromark-util-types"; | |
| 9 | import remarkParse from "remark-parse"; | |
| 10 | import { unified, type Processor } from "unified"; | |
| 11 | ||
| 12 | import { | |
| 13 | codexArtifactTemplatePresentationLabel, | |
| 14 | resolveCodexArtifactTemplate, | |
| 15 | type CodexArtifactTemplate, | |
| 16 | } from "./codexArtifactTemplates.ts"; | |
| 17 | import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts"; | |
| 18 | ||
| 19 | const COLON = 58; | |
| 20 | const DASH = 45; | |
| 21 | const UNDERSCORE = 95; | |
| 22 | const CODEX_FILE_CITATION_NAME = "codex-file-citation"; | |
| 23 | const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template"; | |
| 24 | ||
| 25 | export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [ | |
| 26 | "dataCodexArtifactTemplate", | |
| 27 | "dataArtifactKind", | |
| 28 | "dataDisplayName", | |
| 29 | "dataGalleryKind", | |
| 30 | "dataSkillDirectory", | |
| 31 | "dataSkillName", | |
| 32 | ] as const; | |
| 33 | ||
| 34 | interface MarkdownPosition { | |
| 35 | readonly start: { readonly offset?: number }; | |
| 36 | readonly end: { readonly offset?: number }; | |
| 37 | } | |
| 38 | ||
| 39 | interface MarkdownAstNode { | |
| 40 | type?: string; | |
| 41 | name?: string; | |
| 42 | value?: string; | |
| 43 | url?: string; | |
| 44 | attributes?: Readonly<Record<string, string | null>>; | |
| 45 | position?: MarkdownPosition; | |
| 46 | data?: { | |
| 47 | codexArtifactTemplate?: CodexArtifactTemplate; | |
| 48 | codexFileCitationMarkdown?: string; | |
| 49 | hName?: string; | |
| 50 | hProperties?: Record<string, unknown>; | |
| 51 | }; | |
| 52 | children?: MarkdownAstNode[]; | |
| 53 | } | |
| 54 | ||
| 55 | interface MarkdownFile { | |
| 56 | readonly value: unknown; | |
| 57 | } | |
| 58 | ||
| 59 | export type CodexArtifactTemplateMarkdownSegment = | |
| 60 | | { readonly kind: "markdown"; readonly markdown: string; readonly sourceOffset: number } | |
| 61 | | { | |
| 62 | readonly kind: "artifact-template"; | |
| 63 | readonly sourceOffset: number; | |
| 64 | readonly template: CodexArtifactTemplate; | |
| 65 | }; | |
| 66 | ||
| 67 | function asConstruct(value: Construct | Construct[] | undefined, label: string): Construct { | |
| 68 | const construct = Array.isArray(value) ? value[0] : value; | |
| 69 | if (!construct) throw new Error(`Missing ${label} directive construct`); | |
| 70 | return construct; | |
| 71 | } | |
| 72 | ||
| 73 | function directiveNameEnds(code: number | null): boolean { | |
| 74 | return ( | |
| 75 | code === null || | |
| 76 | markdownLineEnding(code) || | |
| 77 | unicodeWhitespace(code) || | |
| 78 | (unicodePunctuation(code) && code !== DASH && code !== UNDERSCORE) | |
| 79 | ); | |
| 80 | } | |
| 81 | ||
| 82 | function directiveNameGate(markerCount: number, name: string): Construct { | |
| 83 | const tokenize: Tokenizer = (effects, ok, nok) => { | |
| 84 | let markerIndex = 0; | |
| 85 | let nameIndex = 0; | |
| 86 | ||
| 87 | return marker; | |
| 88 | ||
| 89 | function marker(code: number | null) { | |
| 90 | if (code !== COLON) return nok(code); | |
| 91 | if (markerIndex === 0) effects.enter("data"); | |
| 92 | effects.consume(code); | |
| 93 | markerIndex += 1; | |
| 94 | return markerIndex === markerCount ? nameCharacter : marker; | |
| 95 | } | |
| 96 | ||
| 97 | function nameCharacter(code: number | null) { | |
| 98 | if (code !== name.charCodeAt(nameIndex)) return nok(code); | |
| 99 | effects.consume(code); | |
| 100 | nameIndex += 1; | |
| 101 | return nameIndex === name.length ? afterName : nameCharacter; | |
| 102 | } | |
| 103 | ||
| 104 | function afterName(code: number | null) { | |
| 105 | effects.exit("data"); | |
| 106 | return directiveNameEnds(code) ? ok(code) : nok(code); | |
| 107 | } | |
| 108 | }; | |
| 109 | ||
| 110 | return { partial: true, tokenize }; | |
| 111 | } | |
| 112 | ||
| 113 | function restrictedDirective(construct: Construct, markerCount: number, name: string): Construct { | |
| 114 | const gate = directiveNameGate(markerCount, name); | |
| 115 | return { | |
| 116 | ...construct, | |
| 117 | tokenize(effects, ok, nok) { | |
| 118 | return effects.check(gate, construct.tokenize.call(this, effects, ok, nok), nok); | |
| 119 | }, | |
| 120 | }; | |
| 121 | } | |
| 122 | ||
| 123 | function codexDirectiveSyntax(): Extension { | |
| 124 | const genericSyntax = directive(); | |
| 125 | const textDirective = asConstruct(genericSyntax.text?.[COLON], CODEX_FILE_CITATION_NAME); | |
| 126 | const flowDirectives = genericSyntax.flow?.[COLON]; | |
| 127 | const leafDirective = Array.isArray(flowDirectives) | |
| 128 | ? flowDirectives.find((construct) => construct.concrete !== true) | |
| 129 | : flowDirectives; | |
| 130 | if (!leafDirective) | |
| 131 | throw new Error(`Missing ${CODEX_ARTIFACT_TEMPLATE_NAME} directive construct`); | |
| 132 | ||
| 133 | return { | |
| 134 | text: { | |
| 135 | [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME), | |
| 136 | }, | |
| 137 | flow: { | |
| 138 | [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME), | |
| 139 | }, | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | const CODEX_DIRECTIVE_SYNTAX = codexDirectiveSyntax(); | |
| 144 | const CODEX_DIRECTIVE_FROM_MARKDOWN = directiveFromMarkdown(); | |
| 145 | ||
| 146 | function sourceForNode(node: MarkdownAstNode, source: string): string { | |
| 147 | const start = node.position?.start.offset; | |
| 148 | const end = node.position?.end.offset; | |
| 149 | return start === undefined || end === undefined ? "" : source.slice(start, end); | |
| 150 | } | |
| 151 | ||
| 152 | function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string { | |
| 153 | const prefix = `${marker}${node.name ?? ""}`; | |
| 154 | const slicedSource = sourceForNode(node, source); | |
| 155 | if (slicedSource.startsWith(prefix)) return slicedSource; | |
| 156 | ||
| 157 | const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) => | |
| 158 | value === null ? name : `${name}=${JSON.stringify(value)}`, | |
| 159 | ); | |
| 160 | return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`; | |
| 161 | } | |
| 162 | ||
| 163 | function restoreTextDirective(node: MarkdownAstNode, source: string): void { | |
| 164 | node.type = "text"; | |
| 165 | node.value = sourceForDirective(node, source, ":"); | |
| 166 | delete node.name; | |
| 167 | delete node.attributes; | |
| 168 | delete node.url; | |
| 169 | delete node.data; | |
| 170 | delete node.children; | |
| 171 | } | |
| 172 | ||
| 173 | function restoreLeafDirective(node: MarkdownAstNode, source: string): void { | |
| 174 | const value = sourceForDirective(node, source, "::"); | |
| 175 | node.type = "paragraph"; | |
| 176 | node.children = [ | |
| 177 | { type: "text", value, ...(node.position === undefined ? {} : { position: node.position }) }, | |
| 178 | ]; | |
| 179 | delete node.name; | |
| 180 | delete node.attributes; | |
| 181 | delete node.value; | |
| 182 | delete node.url; | |
| 183 | delete node.data; | |
| 184 | } | |
| 185 | ||
| 186 | function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: boolean): void { | |
| 187 | const citation = resolveCodexFileCitationLink(node.attributes); | |
| 188 | if (!citation || insideLink) { | |
| 189 | restoreTextDirective(node, source); | |
| 190 | return; | |
| 191 | } | |
| 192 | ||
| 193 | node.type = "link"; | |
| 194 | node.url = citation.href; | |
| 195 | node.children = [{ type: "text", value: citation.label }]; | |
| 196 | node.data = { codexFileCitationMarkdown: codexFileCitationMarkdown(citation) }; | |
| 197 | delete node.name; | |
| 198 | delete node.attributes; | |
| 199 | delete node.value; | |
| 200 | } | |
| 201 | ||
| 202 | function renderArtifactTemplate(node: MarkdownAstNode, source: string): void { | |
| 203 | const template = resolveCodexArtifactTemplate(node.attributes); | |
| 204 | if (!template) { | |
| 205 | restoreLeafDirective(node, source); | |
| 206 | return; | |
| 207 | } | |
| 208 | ||
| 209 | node.type = "paragraph"; | |
| 210 | node.children = []; | |
| 211 | node.data = { | |
| 212 | codexArtifactTemplate: template, | |
| 213 | hName: "div", | |
| 214 | hProperties: { | |
| 215 | dataCodexArtifactTemplate: "true", | |
| 216 | dataArtifactKind: template.artifactKind, | |
| 217 | dataDisplayName: template.displayName, | |
| 218 | ...(template.galleryKind === undefined ? {} : { dataGalleryKind: template.galleryKind }), | |
| 219 | dataSkillDirectory: template.skillDirectory, | |
| 220 | dataSkillName: template.skillName, | |
| 221 | }, | |
| 222 | }; | |
| 223 | delete node.name; | |
| 224 | delete node.attributes; | |
| 225 | delete node.value; | |
| 226 | delete node.url; | |
| 227 | } | |
| 228 | ||
| 229 | function transformCodexDirectives(node: MarkdownAstNode, source: string, insideLink = false): void { | |
| 230 | if (node.type === "textDirective" && node.name === CODEX_FILE_CITATION_NAME) { | |
| 231 | renderFileCitation(node, source, insideLink); | |
| 232 | return; | |
| 233 | } | |
| 234 | if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) { | |
| 235 | renderArtifactTemplate(node, source); | |
| 236 | return; | |
| 237 | } | |
| 238 | ||
| 239 | const childrenInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; | |
| 240 | for (const child of node.children ?? []) { | |
| 241 | transformCodexDirectives(child, source, childrenInsideLink); | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | /** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */ | |
| 246 | function attachCodexDirectives(this: Processor) { | |
| 247 | const data = this.data(); | |
| 248 | const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []); | |
| 249 | const fromMarkdownExtensions = data.fromMarkdownExtensions ?? (data.fromMarkdownExtensions = []); | |
| 250 | micromarkExtensions.push(CODEX_DIRECTIVE_SYNTAX); | |
| 251 | fromMarkdownExtensions.push(CODEX_DIRECTIVE_FROM_MARKDOWN); | |
| 252 | ||
| 253 | return (tree: unknown, file: MarkdownFile) => { | |
| 254 | transformCodexDirectives(tree as MarkdownAstNode, String(file.value)); | |
| 255 | }; | |
| 256 | } | |
| 257 | ||
| 258 | export const remarkCodexDirectives = attachCodexDirectives; | |
| 259 | ||
| 260 | const directiveParser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); | |
| 261 | ||
| 262 | function parseCodexMarkdown(markdown: string): MarkdownAstNode { | |
| 263 | return directiveParser.runSync(directiveParser.parse(markdown), { | |
| 264 | value: markdown, | |
| 265 | }) as MarkdownAstNode; | |
| 266 | } | |
| 267 | ||
| 268 | interface DirectiveMatch { | |
| 269 | readonly start: number; | |
| 270 | readonly end: number; | |
| 271 | readonly markdown?: string; | |
| 272 | readonly template?: CodexArtifactTemplate; | |
| 273 | } | |
| 274 | ||
| 275 | function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void { | |
| 276 | const start = node.position?.start.offset; | |
| 277 | const end = node.position?.end.offset; | |
| 278 | if (start !== undefined && end !== undefined) { | |
| 279 | if (node.data?.codexFileCitationMarkdown !== undefined) { | |
| 280 | matches.push({ start, end, markdown: node.data.codexFileCitationMarkdown }); | |
| 281 | return; | |
| 282 | } | |
| 283 | if (node.data?.codexArtifactTemplate !== undefined) { | |
| 284 | matches.push({ start, end, template: node.data.codexArtifactTemplate }); | |
| 285 | return; | |
| 286 | } | |
| 287 | } | |
| 288 | for (const child of node.children ?? []) collectDirectiveMatches(child, matches); | |
| 289 | } | |
| 290 | ||
| 291 | function renderDirectiveMatches( | |
| 292 | markdown: string, | |
| 293 | replacementFor: (match: DirectiveMatch) => string | undefined, | |
| 294 | ): string { | |
| 295 | const matches: DirectiveMatch[] = []; | |
| 296 | collectDirectiveMatches(parseCodexMarkdown(markdown), matches); | |
| 297 | let rendered = markdown; | |
| 298 | for (const match of matches.sort((left, right) => right.start - left.start)) { | |
| 299 | const replacement = replacementFor(match); | |
| 300 | if (replacement !== undefined) { | |
| 301 | rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end); | |
| 302 | } | |
| 303 | } | |
| 304 | return rendered; | |
| 305 | } | |
| 306 | ||
| 307 | /** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */ | |
| 308 | export function renderCodexFileCitationsAsMarkdown(markdown: string): string { | |
| 309 | if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown; | |
| 310 | ||
| 311 | return renderDirectiveMatches(markdown, (match) => match.markdown); | |
| 312 | } | |
| 313 | ||
| 314 | /** Matches the Markdown emitted when users copy rendered Codex directive UI. */ | |
| 315 | export function renderCodexDirectivesForCopy(markdown: string): string { | |
| 316 | if ( | |
| 317 | !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && | |
| 318 | !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`) | |
| 319 | ) { | |
| 320 | return markdown; | |
| 321 | } | |
| 322 | ||
| 323 | return renderDirectiveMatches(markdown, (match) => { | |
| 324 | if (match.markdown !== undefined) return match.markdown; | |
| 325 | if (match.template === undefined) return undefined; | |
| 326 | return `${match.template.displayName} (${codexArtifactTemplatePresentationLabel(match.template.artifactKind)})`; | |
| 327 | }); | |
| 328 | } | |
| 329 | ||
| 330 | /** Native renderers split cards out because they cannot host a view inside Markdown text. */ | |
| 331 | export function splitCodexArtifactTemplateMarkdown( | |
| 332 | markdown: string, | |
| 333 | ): ReadonlyArray<CodexArtifactTemplateMarkdownSegment> { | |
| 334 | if (!markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)) { | |
| 335 | return [{ kind: "markdown", markdown, sourceOffset: 0 }]; | |
| 336 | } | |
| 337 | ||
| 338 | const matches: DirectiveMatch[] = []; | |
| 339 | collectDirectiveMatches(parseCodexMarkdown(markdown), matches); | |
| 340 | const templates = matches | |
| 341 | .filter( | |
| 342 | (match): match is DirectiveMatch & { readonly template: CodexArtifactTemplate } => | |
| 343 | match.template !== undefined, | |
| 344 | ) | |
| 345 | .sort((left, right) => left.start - right.start); | |
| 346 | if (templates.length === 0) { | |
| 347 | return [{ kind: "markdown", markdown, sourceOffset: 0 }]; | |
| 348 | } | |
| 349 | ||
| 350 | const segments: CodexArtifactTemplateMarkdownSegment[] = []; | |
| 351 | let cursor = 0; | |
| 352 | for (const match of templates) { | |
| 353 | if (match.start > cursor) { | |
| 354 | segments.push({ | |
| 355 | kind: "markdown", | |
| 356 | markdown: markdown.slice(cursor, match.start), | |
| 357 | sourceOffset: cursor, | |
| 358 | }); | |
| 359 | } | |
| 360 | segments.push({ | |
| 361 | kind: "artifact-template", | |
| 362 | sourceOffset: match.start, | |
| 363 | template: match.template, | |
| 364 | }); | |
| 365 | cursor = match.end; | |
| 366 | } | |
| 367 | if (cursor < markdown.length) { | |
| 368 | segments.push({ kind: "markdown", markdown: markdown.slice(cursor), sourceOffset: cursor }); | |
| 369 | } | |
| 370 | return segments; | |
| 371 | } | |
| 372 | ||
| 373 | export function artifactTemplateFromHastProperties( | |
| 374 | properties: Readonly<Record<string, unknown>> | null | undefined, | |
| 375 | ): CodexArtifactTemplate | null { | |
| 376 | if (properties?.dataCodexArtifactTemplate !== "true") return null; | |
| 377 | const stringProperty = (name: string) => { | |
| 378 | const value = properties[name]; | |
| 379 | return typeof value === "string" ? value : undefined; | |
| 380 | }; | |
| 381 | return resolveCodexArtifactTemplate({ | |
| 382 | artifact_kind: stringProperty("dataArtifactKind"), | |
| 383 | display_name: stringProperty("dataDisplayName"), | |
| 384 | gallery_kind: stringProperty("dataGalleryKind"), | |
| 385 | skill_directory: stringProperty("dataSkillDirectory"), | |
| 386 | skill_name: stringProperty("dataSkillName"), | |
| 387 | }); | |
| 388 | } |
pnpm-lock.yaml+48| ... | ... | @@ -784,10 +784,28 @@ importers: |
| 784 | 784 | effect: |
| 785 | 785 | specifier: 4.0.0-beta.103 |
| 786 | 786 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) |
| 787 | mdast-util-directive: | |
| 788 | specifier: ^3.1.0 | |
| 789 | version: 3.1.0 | |
| 790 | micromark-extension-directive: | |
| 791 | specifier: ^4.0.0 | |
| 792 | version: 4.0.0 | |
| 793 | micromark-util-character: | |
| 794 | specifier: ^2.1.1 | |
| 795 | version: 2.1.1 | |
| 796 | remark-parse: | |
| 797 | specifier: ^11.0.0 | |
| 798 | version: 11.0.0 | |
| 799 | unified: | |
| 800 | specifier: ^11.0.5 | |
| 801 | version: 11.0.5 | |
| 787 | 802 | devDependencies: |
| 788 | 803 | '@effect/vitest': |
| 789 | 804 | specifier: 4.0.0-beta.103 |
| 790 | 805 | version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) |
| 806 | micromark-util-types: | |
| 807 | specifier: ^2.0.2 | |
| 808 | version: 2.0.2 | |
| 791 | 809 | vite-plus: |
| 792 | 810 | specifier: 'catalog:' |
| 793 | 811 | version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| ... | ... | @@ -7726,6 +7744,9 @@ packages: |
| 7726 | 7744 | mdast-util-definitions@6.0.0: |
| 7727 | 7745 | resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} |
| 7728 | 7746 | |
| 7747 | mdast-util-directive@3.1.0: | |
| 7748 | resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} | |
| 7749 | ||
| 7729 | 7750 | mdast-util-find-and-replace@3.0.2: |
| 7730 | 7751 | resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} |
| 7731 | 7752 | |
| ... | ... | @@ -7923,6 +7944,9 @@ packages: |
| 7923 | 7944 | micromark-core-commonmark@2.0.3: |
| 7924 | 7945 | resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} |
| 7925 | 7946 | |
| 7947 | micromark-extension-directive@4.0.0: | |
| 7948 | resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} | |
| 7949 | ||
| 7926 | 7950 | micromark-extension-gfm-autolink-literal@2.1.0: |
| 7927 | 7951 | resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} |
| 7928 | 7952 | |
| ... | ... | @@ -17805,6 +17829,20 @@ snapshots: |
| 17805 | 17829 | unist-util-visit: 5.1.0 |
| 17806 | 17830 | optional: true |
| 17807 | 17831 | |
| 17832 | mdast-util-directive@3.1.0: | |
| 17833 | dependencies: | |
| 17834 | '@types/mdast': 4.0.4 | |
| 17835 | '@types/unist': 3.0.3 | |
| 17836 | ccount: 2.0.1 | |
| 17837 | devlop: 1.1.0 | |
| 17838 | mdast-util-from-markdown: 2.0.3 | |
| 17839 | mdast-util-to-markdown: 2.1.2 | |
| 17840 | parse-entities: 4.0.2 | |
| 17841 | stringify-entities: 4.0.4 | |
| 17842 | unist-util-visit-parents: 6.0.2 | |
| 17843 | transitivePeerDependencies: | |
| 17844 | - supports-color | |
| 17845 | ||
| 17808 | 17846 | mdast-util-find-and-replace@3.0.2: |
| 17809 | 17847 | dependencies: |
| 17810 | 17848 | '@types/mdast': 4.0.4 |
| ... | ... | @@ -18347,6 +18385,16 @@ snapshots: |
| 18347 | 18385 | micromark-util-symbol: 2.0.1 |
| 18348 | 18386 | micromark-util-types: 2.0.2 |
| 18349 | 18387 | |
| 18388 | micromark-extension-directive@4.0.0: | |
| 18389 | dependencies: | |
| 18390 | devlop: 1.1.0 | |
| 18391 | micromark-factory-space: 2.0.1 | |
| 18392 | micromark-factory-whitespace: 2.0.1 | |
| 18393 | micromark-util-character: 2.1.1 | |
| 18394 | micromark-util-symbol: 2.0.1 | |
| 18395 | micromark-util-types: 2.0.2 | |
| 18396 | parse-entities: 4.0.2 | |
| 18397 | ||
| 18350 | 18398 | micromark-extension-gfm-autolink-literal@2.1.0: |
| 18351 | 18399 | dependencies: |
| 18352 | 18400 | micromark-util-character: 2.1.1 |