| 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 | 179 | }); |
| 180 | 180 | |
| 181 | 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 | 251 | it("does not retokenize completed lines when streaming finishes", async () => { |
| 183 | 252 | const highlighter = await getSyntaxHighlighterPromise("typescript"); |
| 184 | 253 | const highlight = vi.spyOn(highlighter, "codeToHast"); |
apps/web/src/components/ChatMarkdown.tsx+64-1| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | MessageSquareWarningIcon, |
| 21 | 21 | Minimize2Icon, |
| 22 | 22 | OctagonAlertIcon, |
| 23 | PlayIcon, | |
| 23 | 24 | PresentationIcon, |
| 24 | 25 | SparklesIcon, |
| 25 | 26 | TriangleAlertIcon, |
| ... | ... | @@ -215,6 +216,7 @@ interface ChatMarkdownProps { |
| 215 | 216 | parseRawHtml?: boolean; |
| 216 | 217 | /** Append a prompt that invokes a newly created artifact-template skill. */ |
| 217 | 218 | onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; |
| 219 | onRunShellCommand?: ((command: string) => void) | undefined; | |
| 218 | 220 | /** Directory that anchors relative links and images; defaults to `cwd`. Set |
| 219 | 221 | to the file's own directory when rendering a markdown file. */ |
| 220 | 222 | imageBaseDir?: string | undefined; |
| ... | ... | @@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined { |
| 585 | 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 | 607 | type MarkdownAstNode = { |
| 589 | 608 | type?: string; |
| 590 | 609 | meta?: unknown; |
| ... | ... | @@ -920,12 +939,16 @@ function MarkdownCodeBlock({ |
| 920 | 939 | language, |
| 921 | 940 | fenceTitle, |
| 922 | 941 | theme, |
| 942 | onRunShellCommand, | |
| 943 | isStreaming, | |
| 923 | 944 | children, |
| 924 | 945 | }: { |
| 925 | 946 | code: string; |
| 926 | 947 | language: string; |
| 927 | 948 | fenceTitle: string | null; |
| 928 | 949 | theme: "light" | "dark"; |
| 950 | onRunShellCommand?: ((command: string) => void) | undefined; | |
| 951 | isStreaming: boolean; | |
| 929 | 952 | children: ReactNode; |
| 930 | 953 | }) { |
| 931 | 954 | const [copied, setCopied] = useState(false); |
| ... | ... | @@ -933,6 +956,17 @@ function MarkdownCodeBlock({ |
| 933 | 956 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 934 | 957 | const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; |
| 935 | 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 | 971 | const handleCopy = useCallback(() => { |
| 938 | 972 | if (typeof navigator === "undefined" || navigator.clipboard == null) { |
| ... | ... | @@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({ |
| 1004 | 1038 | </TooltipTrigger> |
| 1005 | 1039 | <TooltipPopup side="top">{wrapLabel}</TooltipPopup> |
| 1006 | 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 | 1059 | <Tooltip> |
| 1008 | 1060 | <TooltipTrigger |
| 1009 | 1061 | render={ |
| ... | ... | @@ -2244,6 +2296,7 @@ function useChatMarkdownState({ |
| 2244 | 2296 | isStreaming = false, |
| 2245 | 2297 | skills = EMPTY_MARKDOWN_SKILLS, |
| 2246 | 2298 | onUseArtifactTemplate, |
| 2299 | onRunShellCommand, | |
| 2247 | 2300 | imageBaseDir, |
| 2248 | 2301 | onImageExpand, |
| 2249 | 2302 | renderContextReference, |
| ... | ... | @@ -2650,6 +2703,7 @@ function useChatMarkdownState({ |
| 2650 | 2703 | markdownFileLinkMetaByHref, |
| 2651 | 2704 | onTaskListChange, |
| 2652 | 2705 | onUseArtifactTemplate, |
| 2706 | onRunShellCommand, | |
| 2653 | 2707 | openChangeRequestLink, |
| 2654 | 2708 | openDeferredMarkdownLink, |
| 2655 | 2709 | openExternalLinkInPreview, |
| ... | ... | @@ -2680,6 +2734,7 @@ function useChatMarkdownState({ |
| 2680 | 2734 | markdownFileLinkMetaByHref, |
| 2681 | 2735 | onTaskListChange, |
| 2682 | 2736 | onUseArtifactTemplate, |
| 2737 | onRunShellCommand, | |
| 2683 | 2738 | openChangeRequestLink, |
| 2684 | 2739 | openDeferredMarkdownLink, |
| 2685 | 2740 | openExternalLinkInPreview, |
| ... | ... | @@ -3211,7 +3266,9 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3211 | 3266 | return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>; |
| 3212 | 3267 | }, |
| 3213 | 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 | 3272 | const codeBlock = extractCodeBlock(children); |
| 3216 | 3273 | if (!codeBlock) { |
| 3217 | 3274 | return <pre {...props}>{children}</pre>; |
| ... | ... | @@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3225 | 3282 | language={language} |
| 3226 | 3283 | fenceTitle={fenceTitle} |
| 3227 | 3284 | theme={resolvedTheme} |
| 3285 | onRunShellCommand={ | |
| 3286 | onRunShellCommand && !isStreaming && isClosedCodeFence(node, text) | |
| 3287 | ? onRunShellCommand | |
| 3288 | : undefined | |
| 3289 | } | |
| 3290 | isStreaming={isStreaming} | |
| 3228 | 3291 | > |
| 3229 | 3292 | <RenderErrorBoundary |
| 3230 | 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 | 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 | 4333 | const supportsProjectSettingsOverrides = |
| 4317 | 4334 | environmentById.get(environmentId)?.serverConfig?.environment.capabilities |
| 4318 | 4335 | .projectSettingsOverrides === true; |
| ... | ... | @@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9902 | 9919 | agentPanelModel, |
| 9903 | 9920 | onOpenAgents: addAgentsSurface, |
| 9904 | 9921 | onUseArtifactTemplate: useArtifactTemplate, |
| 9922 | ...(activeProject ? { onRunShellCommand: runShellCommand } : {}), | |
| 9905 | 9923 | } |
| 9906 | 9924 | : {})} |
| 9907 | 9925 | isWorking={!paintOnlyDisplayedTimeline && isWorking} |
apps/web/src/components/chat/MessagesTimeline.tsx+6| ... | ... | @@ -277,6 +277,7 @@ interface TimelineRowSharedState { |
| 277 | 277 | activeThreadEnvironmentId: EnvironmentId; |
| 278 | 278 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; |
| 279 | 279 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; |
| 280 | onRunShellCommand: ((command: string) => void) | undefined; | |
| 280 | 281 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 281 | 282 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| 282 | 283 | onFileDownload: (attachment: ChatFileAttachment) => void; |
| ... | ... | @@ -425,6 +426,7 @@ interface MessagesTimelineProps { |
| 425 | 426 | supportsConversationRollback: boolean; |
| 426 | 427 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; |
| 427 | 428 | onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; |
| 429 | onRunShellCommand?: (command: string) => void; | |
| 428 | 430 | isRevertingCheckpoint: boolean; |
| 429 | 431 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 430 | 432 | onFileOpen?: (attachment: ChatFileAttachment) => void; |
| ... | ... | @@ -494,6 +496,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 494 | 496 | supportsConversationRollback, |
| 495 | 497 | onRevertToTurnCount, |
| 496 | 498 | onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, |
| 499 | onRunShellCommand, | |
| 497 | 500 | isRevertingCheckpoint, |
| 498 | 501 | onImageExpand, |
| 499 | 502 | onFileOpen = NOOP_OPEN_ATTACHMENT, |
| ... | ... | @@ -1138,6 +1141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1138 | 1141 | activeThreadEnvironmentId, |
| 1139 | 1142 | onRevertToTurnCount, |
| 1140 | 1143 | onUseArtifactTemplate, |
| 1144 | onRunShellCommand, | |
| 1141 | 1145 | onImageExpand, |
| 1142 | 1146 | onFileOpen, |
| 1143 | 1147 | onFileDownload, |
| ... | ... | @@ -1173,6 +1177,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1173 | 1177 | activeThreadEnvironmentId, |
| 1174 | 1178 | onRevertToTurnCount, |
| 1175 | 1179 | onUseArtifactTemplate, |
| 1180 | onRunShellCommand, | |
| 1176 | 1181 | onImageExpand, |
| 1177 | 1182 | onFileOpen, |
| 1178 | 1183 | onFileDownload, |
| ... | ... | @@ -2372,6 +2377,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 2372 | 2377 | skills={ctx.skills} |
| 2373 | 2378 | headingLevelOffset={MESSAGE_HEADING_LEVEL} |
| 2374 | 2379 | onUseArtifactTemplate={ctx.onUseArtifactTemplate} |
| 2380 | onRunShellCommand={ctx.onRunShellCommand} | |
| 2375 | 2381 | onImageExpand={ctx.onImageExpand} |
| 2376 | 2382 | /> |
| 2377 | 2383 | </AssistantCitationSource> |