| author | |
| committer | |
| log | 66129c6fd518aa01d873ebb47060b41c17312d60 |
| tree | 99887e55978a3b210b19bc0a08efc0c3b813c03b |
| parent | e759847f932c0de08261ec98a5fac3bae037120f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 157 insertions(+), 1 deletions(-)
apps/web/src/components/ChatMarkdown.test.tsx+69| ... | @@ -179,6 +179,75 @@ describe("ChatMarkdown favicon privacy", () => { | ... | @@ -179,6 +179,75 @@ describe("ChatMarkdown favicon privacy", () => { |
| 179 | }); | 179 | }); |
| 180 | 180 | ||
| 181 | describe("ChatMarkdown streaming", () => { | 181 | describe("ChatMarkdown streaming", () => { |
| 182 | it("runs only a complete single-line shell block after a click", async () => { | ||
| 183 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 184 | const onRunShellCommand = vi.fn(); | ||
| 185 | let renderer: ReactTestRenderer | undefined; | ||
| 186 | const message = (text: string, isStreaming = false) => ( | ||
| 187 | <ChatMarkdown | ||
| 188 | cwd="/tmp/project" | ||
| 189 | text={text} | ||
| 190 | isStreaming={isStreaming} | ||
| 191 | onRunShellCommand={onRunShellCommand} | ||
| 192 | /> | ||
| 193 | ); | ||
| 194 | try { | ||
| 195 | await act(async () => { | ||
| 196 | renderer = create(message("```bash\necho hello\n```", true)); | ||
| 197 | }); | ||
| 198 | const mounted = renderer!; | ||
| 199 | expect( | ||
| 200 | mounted.root | ||
| 201 | .findAllByType(Button) | ||
| 202 | .some((button) => button.props["aria-label"] === "Run in terminal"), | ||
| 203 | ).toBe(false); | ||
| 204 | |||
| 205 | await act(async () => { | ||
| 206 | mounted.update(message("```bash\necho hello\n```")); | ||
| 207 | }); | ||
| 208 | await act(async () => { | ||
| 209 | codeButton(mounted, "Run in terminal").onClick?.({} as never); | ||
| 210 | }); | ||
| 211 | expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello"); | ||
| 212 | |||
| 213 | for (const text of [ | ||
| 214 | "~~~bash\necho tilde\n~~~", | ||
| 215 | "> ```bash\n> echo quote\n> ```", | ||
| 216 | "````bash\necho four\n````", | ||
| 217 | ]) { | ||
| 218 | await act(async () => { | ||
| 219 | mounted.update(message(text)); | ||
| 220 | }); | ||
| 221 | expect(codeButton(mounted, "Run in terminal")).toBeDefined(); | ||
| 222 | } | ||
| 223 | |||
| 224 | for (const text of [ | ||
| 225 | "```bash\necho one\necho two\n```", | ||
| 226 | "```typescript\necho hello\n```", | ||
| 227 | "```bash\n\n```", | ||
| 228 | "```bash\necho hello\n\n```", | ||
| 229 | "```bash\necho hello\\\n```", | ||
| 230 | "```bash\necho safe \u202e#\n```", | ||
| 231 | "```bash\necho incomplete", | ||
| 232 | "~~~bash\necho incomplete", | ||
| 233 | "````bash\necho incomplete\n```", | ||
| 234 | '<pre><code class="language-bash">echo html</code></pre>', | ||
| 235 | ]) { | ||
| 236 | await act(async () => { | ||
| 237 | mounted.update(message(text)); | ||
| 238 | }); | ||
| 239 | expect( | ||
| 240 | mounted.root | ||
| 241 | .findAllByType(Button) | ||
| 242 | .some((button) => button.props["aria-label"] === "Run in terminal"), | ||
| 243 | ).toBe(false); | ||
| 244 | } | ||
| 245 | } finally { | ||
| 246 | await act(async () => renderer?.unmount()); | ||
| 247 | vi.unstubAllGlobals(); | ||
| 248 | } | ||
| 249 | }); | ||
| 250 | |||
| 182 | it("does not retokenize completed lines when streaming finishes", async () => { | 251 | it("does not retokenize completed lines when streaming finishes", async () => { |
| 183 | const highlighter = await getSyntaxHighlighterPromise("typescript"); | 252 | const highlighter = await getSyntaxHighlighterPromise("typescript"); |
| 184 | const highlight = vi.spyOn(highlighter, "codeToHast"); | 253 | const highlight = vi.spyOn(highlighter, "codeToHast"); |
apps/web/src/components/ChatMarkdown.tsx+64-1| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | MessageSquareWarningIcon, | 20 | MessageSquareWarningIcon, |
| 21 | Minimize2Icon, | 21 | Minimize2Icon, |
| 22 | OctagonAlertIcon, | 22 | OctagonAlertIcon, |
| 23 | PlayIcon, | ||
| 23 | PresentationIcon, | 24 | PresentationIcon, |
| 24 | SparklesIcon, | 25 | SparklesIcon, |
| 25 | TriangleAlertIcon, | 26 | TriangleAlertIcon, |
| ... | @@ -215,6 +216,7 @@ interface ChatMarkdownProps { | ... | @@ -215,6 +216,7 @@ interface ChatMarkdownProps { |
| 215 | parseRawHtml?: boolean; | 216 | parseRawHtml?: boolean; |
| 216 | /** Append a prompt that invokes a newly created artifact-template skill. */ | 217 | /** Append a prompt that invokes a newly created artifact-template skill. */ |
| 217 | onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; | 218 | onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; |
| 219 | onRunShellCommand?: ((command: string) => void) | undefined; | ||
| 218 | /** Directory that anchors relative links and images; defaults to `cwd`. Set | 220 | /** Directory that anchors relative links and images; defaults to `cwd`. Set |
| 219 | to the file's own directory when rendering a markdown file. */ | 221 | to the file's own directory when rendering a markdown file. */ |
| 220 | imageBaseDir?: string | undefined; | 222 | imageBaseDir?: string | undefined; |
| ... | @@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined { | ... | @@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined { |
| 585 | return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; | 587 | return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; |
| 586 | } | 588 | } |
| 587 | 589 | ||
| 590 | function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean { | ||
| 591 | const start = node?.position?.start.offset; | ||
| 592 | const end = node?.position?.end.offset; | ||
| 593 | if (start === undefined || end === undefined) return false; | ||
| 594 | const source = text.slice(start, end); | ||
| 595 | const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0]; | ||
| 596 | // One class for the blockquote prefix: nested quantifiers here backtrack | ||
| 597 | // exponentially on code lines that start with many `> ` markers. | ||
| 598 | const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1]; | ||
| 599 | return ( | ||
| 600 | opening !== undefined && | ||
| 601 | closing !== undefined && | ||
| 602 | opening[0] === closing[0] && | ||
| 603 | closing.length >= opening.length | ||
| 604 | ); | ||
| 605 | } | ||
| 606 | |||
| 588 | type MarkdownAstNode = { | 607 | type MarkdownAstNode = { |
| 589 | type?: string; | 608 | type?: string; |
| 590 | meta?: unknown; | 609 | meta?: unknown; |
| ... | @@ -920,12 +939,16 @@ function MarkdownCodeBlock({ | ... | @@ -920,12 +939,16 @@ function MarkdownCodeBlock({ |
| 920 | language, | 939 | language, |
| 921 | fenceTitle, | 940 | fenceTitle, |
| 922 | theme, | 941 | theme, |
| 942 | onRunShellCommand, | ||
| 943 | isStreaming, | ||
| 923 | children, | 944 | children, |
| 924 | }: { | 945 | }: { |
| 925 | code: string; | 946 | code: string; |
| 926 | language: string; | 947 | language: string; |
| 927 | fenceTitle: string | null; | 948 | fenceTitle: string | null; |
| 928 | theme: "light" | "dark"; | 949 | theme: "light" | "dark"; |
| 950 | onRunShellCommand?: ((command: string) => void) | undefined; | ||
| 951 | isStreaming: boolean; | ||
| 929 | children: ReactNode; | 952 | children: ReactNode; |
| 930 | }) { | 953 | }) { |
| 931 | const [copied, setCopied] = useState(false); | 954 | const [copied, setCopied] = useState(false); |
| ... | @@ -933,6 +956,17 @@ function MarkdownCodeBlock({ | ... | @@ -933,6 +956,17 @@ function MarkdownCodeBlock({ |
| 933 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 956 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 934 | const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; | 957 | const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; |
| 935 | const copyLabel = copied ? "Copied" : "Copy code"; | 958 | const copyLabel = copied ? "Copied" : "Copy code"; |
| 959 | const command = code.trim(); | ||
| 960 | const canRun = | ||
| 961 | onRunShellCommand !== undefined && | ||
| 962 | !isStreaming && | ||
| 963 | /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) && | ||
| 964 | code.endsWith("\n") && | ||
| 965 | command.length > 0 && | ||
| 966 | !command.endsWith("\\") && | ||
| 967 | // Control and invisible format characters (bidi overrides, zero-width) can | ||
| 968 | // make the rendered command differ from what the terminal would receive. | ||
| 969 | !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); | ||
| 936 | 970 | ||
| 937 | const handleCopy = useCallback(() => { | 971 | const handleCopy = useCallback(() => { |
| 938 | if (typeof navigator === "undefined" || navigator.clipboard == null) { | 972 | if (typeof navigator === "undefined" || navigator.clipboard == null) { |
| ... | @@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({ | ... | @@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({ |
| 1004 | </TooltipTrigger> | 1038 | </TooltipTrigger> |
| 1005 | <TooltipPopup side="top">{wrapLabel}</TooltipPopup> | 1039 | <TooltipPopup side="top">{wrapLabel}</TooltipPopup> |
| 1006 | </Tooltip> | 1040 | </Tooltip> |
| 1041 | {canRun ? ( | ||
| 1042 | <Tooltip> | ||
| 1043 | <TooltipTrigger | ||
| 1044 | render={ | ||
| 1045 | <Button | ||
| 1046 | type="button" | ||
| 1047 | variant="ghost-muted" | ||
| 1048 | size="icon-xs" | ||
| 1049 | onClick={() => onRunShellCommand(command)} | ||
| 1050 | aria-label="Run in terminal" | ||
| 1051 | /> | ||
| 1052 | } | ||
| 1053 | > | ||
| 1054 | <PlayIcon className="size-3" /> | ||
| 1055 | </TooltipTrigger> | ||
| 1056 | <TooltipPopup side="top">Run in terminal</TooltipPopup> | ||
| 1057 | </Tooltip> | ||
| 1058 | ) : null} | ||
| 1007 | <Tooltip> | 1059 | <Tooltip> |
| 1008 | <TooltipTrigger | 1060 | <TooltipTrigger |
| 1009 | render={ | 1061 | render={ |
| ... | @@ -2244,6 +2296,7 @@ function useChatMarkdownState({ | ... | @@ -2244,6 +2296,7 @@ function useChatMarkdownState({ |
| 2244 | isStreaming = false, | 2296 | isStreaming = false, |
| 2245 | skills = EMPTY_MARKDOWN_SKILLS, | 2297 | skills = EMPTY_MARKDOWN_SKILLS, |
| 2246 | onUseArtifactTemplate, | 2298 | onUseArtifactTemplate, |
| 2299 | onRunShellCommand, | ||
| 2247 | imageBaseDir, | 2300 | imageBaseDir, |
| 2248 | onImageExpand, | 2301 | onImageExpand, |
| 2249 | renderContextReference, | 2302 | renderContextReference, |
| ... | @@ -2650,6 +2703,7 @@ function useChatMarkdownState({ | ... | @@ -2650,6 +2703,7 @@ function useChatMarkdownState({ |
| 2650 | markdownFileLinkMetaByHref, | 2703 | markdownFileLinkMetaByHref, |
| 2651 | onTaskListChange, | 2704 | onTaskListChange, |
| 2652 | onUseArtifactTemplate, | 2705 | onUseArtifactTemplate, |
| 2706 | onRunShellCommand, | ||
| 2653 | openChangeRequestLink, | 2707 | openChangeRequestLink, |
| 2654 | openDeferredMarkdownLink, | 2708 | openDeferredMarkdownLink, |
| 2655 | openExternalLinkInPreview, | 2709 | openExternalLinkInPreview, |
| ... | @@ -2680,6 +2734,7 @@ function useChatMarkdownState({ | ... | @@ -2680,6 +2734,7 @@ function useChatMarkdownState({ |
| 2680 | markdownFileLinkMetaByHref, | 2734 | markdownFileLinkMetaByHref, |
| 2681 | onTaskListChange, | 2735 | onTaskListChange, |
| 2682 | onUseArtifactTemplate, | 2736 | onUseArtifactTemplate, |
| 2737 | onRunShellCommand, | ||
| 2683 | openChangeRequestLink, | 2738 | openChangeRequestLink, |
| 2684 | openDeferredMarkdownLink, | 2739 | openDeferredMarkdownLink, |
| 2685 | openExternalLinkInPreview, | 2740 | openExternalLinkInPreview, |
| ... | @@ -3211,7 +3266,9 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -3211,7 +3266,9 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3211 | return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>; | 3266 | return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>; |
| 3212 | }, | 3267 | }, |
| 3213 | pre: function MarkdownPre({ node, children, ...props }) { | 3268 | pre: function MarkdownPre({ node, children, ...props }) { |
| 3214 | const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext); | 3269 | const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use( |
| 3270 | ChatMarkdownRendererContext, | ||
| 3271 | ); | ||
| 3215 | const codeBlock = extractCodeBlock(children); | 3272 | const codeBlock = extractCodeBlock(children); |
| 3216 | if (!codeBlock) { | 3273 | if (!codeBlock) { |
| 3217 | return <pre {...props}>{children}</pre>; | 3274 | return <pre {...props}>{children}</pre>; |
| ... | @@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3225 | language={language} | 3282 | language={language} |
| 3226 | fenceTitle={fenceTitle} | 3283 | fenceTitle={fenceTitle} |
| 3227 | theme={resolvedTheme} | 3284 | theme={resolvedTheme} |
| 3285 | onRunShellCommand={ | ||
| 3286 | onRunShellCommand && !isStreaming && isClosedCodeFence(node, text) | ||
| 3287 | ? onRunShellCommand | ||
| 3288 | : undefined | ||
| 3289 | } | ||
| 3290 | isStreaming={isStreaming} | ||
| 3228 | > | 3291 | > |
| 3229 | <RenderErrorBoundary | 3292 | <RenderErrorBoundary |
| 3230 | resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} | 3293 | resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} |
apps/web/src/components/ChatView.tsx+18| ... | @@ -4313,6 +4313,23 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4313,6 +4313,23 @@ export default function ChatView(props: ChatViewProps) { |
| 4313 | ], | 4313 | ], |
| 4314 | ); | 4314 | ); |
| 4315 | 4315 | ||
| 4316 | const runProjectScriptRef = useRef(runProjectScript); | ||
| 4317 | useLayoutEffect(() => { | ||
| 4318 | runProjectScriptRef.current = runProjectScript; | ||
| 4319 | }, [runProjectScript]); | ||
| 4320 | const runShellCommand = useCallback((command: string) => { | ||
| 4321 | void runProjectScriptRef.current( | ||
| 4322 | { | ||
| 4323 | id: "chat-code-block", | ||
| 4324 | name: "Chat code block", | ||
| 4325 | command, | ||
| 4326 | icon: "play", | ||
| 4327 | runOnWorktreeCreate: false, | ||
| 4328 | }, | ||
| 4329 | { rememberAsLastInvoked: false }, | ||
| 4330 | ); | ||
| 4331 | }, []); | ||
| 4332 | |||
| 4316 | const supportsProjectSettingsOverrides = | 4333 | const supportsProjectSettingsOverrides = |
| 4317 | environmentById.get(environmentId)?.serverConfig?.environment.capabilities | 4334 | environmentById.get(environmentId)?.serverConfig?.environment.capabilities |
| 4318 | .projectSettingsOverrides === true; | 4335 | .projectSettingsOverrides === true; |
| ... | @@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9902 | agentPanelModel, | 9919 | agentPanelModel, |
| 9903 | onOpenAgents: addAgentsSurface, | 9920 | onOpenAgents: addAgentsSurface, |
| 9904 | onUseArtifactTemplate: useArtifactTemplate, | 9921 | onUseArtifactTemplate: useArtifactTemplate, |
| 9922 | ...(activeProject ? { onRunShellCommand: runShellCommand } : {}), | ||
| 9905 | } | 9923 | } |
| 9906 | : {})} | 9924 | : {})} |
| 9907 | isWorking={!paintOnlyDisplayedTimeline && isWorking} | 9925 | isWorking={!paintOnlyDisplayedTimeline && isWorking} |
apps/web/src/components/chat/MessagesTimeline.tsx+6| ... | @@ -277,6 +277,7 @@ interface TimelineRowSharedState { | ... | @@ -277,6 +277,7 @@ interface TimelineRowSharedState { |
| 277 | activeThreadEnvironmentId: EnvironmentId; | 277 | activeThreadEnvironmentId: EnvironmentId; |
| 278 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; | 278 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; |
| 279 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; | 279 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; |
| 280 | onRunShellCommand: ((command: string) => void) | undefined; | ||
| 280 | onImageExpand: (preview: ExpandedImagePreview) => void; | 281 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 281 | onFileOpen: (attachment: ChatFileAttachment) => void; | 282 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| 282 | onFileDownload: (attachment: ChatFileAttachment) => void; | 283 | onFileDownload: (attachment: ChatFileAttachment) => void; |
| ... | @@ -425,6 +426,7 @@ interface MessagesTimelineProps { | ... | @@ -425,6 +426,7 @@ interface MessagesTimelineProps { |
| 425 | supportsConversationRollback: boolean; | 426 | supportsConversationRollback: boolean; |
| 426 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; | 427 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; |
| 427 | onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; | 428 | onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; |
| 429 | onRunShellCommand?: (command: string) => void; | ||
| 428 | isRevertingCheckpoint: boolean; | 430 | isRevertingCheckpoint: boolean; |
| 429 | onImageExpand: (preview: ExpandedImagePreview) => void; | 431 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 430 | onFileOpen?: (attachment: ChatFileAttachment) => void; | 432 | onFileOpen?: (attachment: ChatFileAttachment) => void; |
| ... | @@ -494,6 +496,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -494,6 +496,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 494 | supportsConversationRollback, | 496 | supportsConversationRollback, |
| 495 | onRevertToTurnCount, | 497 | onRevertToTurnCount, |
| 496 | onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, | 498 | onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, |
| 499 | onRunShellCommand, | ||
| 497 | isRevertingCheckpoint, | 500 | isRevertingCheckpoint, |
| 498 | onImageExpand, | 501 | onImageExpand, |
| 499 | onFileOpen = NOOP_OPEN_ATTACHMENT, | 502 | onFileOpen = NOOP_OPEN_ATTACHMENT, |
| ... | @@ -1138,6 +1141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -1138,6 +1141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1138 | activeThreadEnvironmentId, | 1141 | activeThreadEnvironmentId, |
| 1139 | onRevertToTurnCount, | 1142 | onRevertToTurnCount, |
| 1140 | onUseArtifactTemplate, | 1143 | onUseArtifactTemplate, |
| 1144 | onRunShellCommand, | ||
| 1141 | onImageExpand, | 1145 | onImageExpand, |
| 1142 | onFileOpen, | 1146 | onFileOpen, |
| 1143 | onFileDownload, | 1147 | onFileDownload, |
| ... | @@ -1173,6 +1177,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -1173,6 +1177,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1173 | activeThreadEnvironmentId, | 1177 | activeThreadEnvironmentId, |
| 1174 | onRevertToTurnCount, | 1178 | onRevertToTurnCount, |
| 1175 | onUseArtifactTemplate, | 1179 | onUseArtifactTemplate, |
| 1180 | onRunShellCommand, | ||
| 1176 | onImageExpand, | 1181 | onImageExpand, |
| 1177 | onFileOpen, | 1182 | onFileOpen, |
| 1178 | onFileDownload, | 1183 | onFileDownload, |
| ... | @@ -2372,6 +2377,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -2372,6 +2377,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 2372 | skills={ctx.skills} | 2377 | skills={ctx.skills} |
| 2373 | headingLevelOffset={MESSAGE_HEADING_LEVEL} | 2378 | headingLevelOffset={MESSAGE_HEADING_LEVEL} |
| 2374 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} | 2379 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} |
| 2380 | onRunShellCommand={ctx.onRunShellCommand} | ||
| 2375 | onImageExpand={ctx.onImageExpand} | 2381 | onImageExpand={ctx.onImageExpand} |
| 2376 | /> | 2382 | /> |
| 2377 | </AssistantCitationSource> | 2383 | </AssistantCitationSource> |