From ce7e5e722514eb9ed2be7ea2acaf0b3472dbe109 Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 23 Sep 2026 00:53:40 +0800 Subject: [PATCH] perf(flowchat): Keep long thinking lightweight Use Markstream for thinking Markdown while preserving the response renderer and existing safe HTML, math, image, and navigation behavior. Reuse unchanged parsed trees so settled thinking blocks skip renders. Remove thinking arrival fading because its full-document DOM scans cause frame drops on long streams. Keep thinking code fences as plain source permanently, including Mermaid and completed/reopened content, to avoid bulk highlighting stalls. Preserve line numbers and copying. Document both restrictions to prevent reintroducing these costs. Remove temporary performance probes after runtime verification. Validation: 105 focused tests, check:web, ESLint, and diff checks pass. --- pnpm-lock.yaml | 148 +++++++++++++++ src/web-ui/package.json | 2 + .../tool-cards/ModelThinkingDisplay.test.tsx | 2 +- .../tool-cards/ModelThinkingDisplay.tsx | 4 +- .../ModelThinkingDisplay.typewriter.test.tsx | 2 +- .../markdown/MarkdownMathRenderer.tsx | 13 +- .../markdown/MarkdownRenderer.test.tsx | 134 ++++++++++++- .../markdown/MarkdownRenderer.tsx | 146 ++++++++++---- .../markdown/ThinkingMarkdown.test.tsx | 163 ++++++++++++++++ .../markdown/ThinkingMarkdown.tsx | 179 ++++++++++++++++++ .../src/infrastructure/markdown/index.ts | 2 +- .../markdown/stabilizeThinkingNodes.test.ts | 44 +++++ .../markdown/stabilizeThinkingNodes.ts | 36 ++++ .../markdown/useStreamingTextReveal.test.tsx | 29 ++- .../markdown/useStreamingTextReveal.ts | 15 +- 15 files changed, 861 insertions(+), 58 deletions(-) create mode 100644 src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.test.tsx create mode 100644 src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.tsx create mode 100644 src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.test.ts create mode 100644 src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73004ffc06..9a068d8707 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,6 +480,9 @@ importers: lucide-react: specifier: ^0.541.0 version: 0.541.0(react@18.3.1) + markstream-react: + specifier: 2.0.13 + version: 2.0.13(katex@0.16.28)(mermaid@11.12.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) mermaid: specifier: ^11.10.1 version: 11.12.2 @@ -555,6 +558,9 @@ importers: semver: specifier: 7.7.4 version: 7.7.4 + stream-markdown-parser: + specifier: 1.2.16 + version: 1.2.16 tabbable: specifier: ^6.5.0 version: 6.5.0 @@ -1111,12 +1117,21 @@ packages: '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -3026,6 +3041,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} @@ -3493,6 +3512,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -4279,6 +4302,9 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} + linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} @@ -4378,6 +4404,28 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + markdown-it-container@4.0.0: + resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==} + + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + + markdown-it-ins@4.0.0: + resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==} + + markdown-it-mark@4.0.0: + resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==} + + markdown-it-sup@2.0.0: + resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==} + + markdown-it-task-checkbox@1.0.6: + resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==} + + markdown-it-ts@1.1.2: + resolution: {integrity: sha512-17qO3LQLK+JXom5Zt6Z9l7OGWWv9eYquTxdj3xIPGe1OBHdYXrzNYA3xCgCyIj1uItv4jkqbQf6FkaqifKkySw==} + engines: {node: '>=20.19'} + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -4390,6 +4438,31 @@ packages: engines: {node: '>= 20'} hasBin: true + markstream-core@2.0.13: + resolution: {integrity: sha512-uHypWvBcaTEQ1XJQ7O1hAG4onhkrscNW0qkSTqxx592d9bykfmOmLMp10IsSqfhRLrBh7CbpzzISvBItd2cKjQ==} + + markstream-react@2.0.13: + resolution: {integrity: sha512-+/PTJm2MTPpKIR9F5dfav0AUlNzkLzJtwKyG1iEHOedGEByr7o9SvdxpXYZrzKkyecrrXy+fcicpEHMBc46jDA==} + peerDependencies: + '@antv/infographic': ^0.2.3 + '@terrastruct/d2': '>=0.1.33' + katex: '>=0.16.22' + mermaid: '>=11' + react: '>=18' + react-dom: '>=18' + stream-diffs: '>=0.0.2' + peerDependenciesMeta: + '@antv/infographic': + optional: true + '@terrastruct/d2': + optional: true + katex: + optional: true + mermaid: + optional: true + stream-diffs: + optional: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -4447,6 +4520,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + mermaid@11.12.2: resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} @@ -5367,6 +5443,9 @@ packages: resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} engines: {node: '>= 0.10.0'} + stream-markdown-parser@1.2.16: + resolution: {integrity: sha512-5MEAPd3zxbwvpr8jimBMaptNOPoALvKwUOAVIzXk/92SmOppEN+ZWTinNeFcS219qFjlt08mDLGHW0MDTIlXNA==} + stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} @@ -5603,6 +5682,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -6500,15 +6582,26 @@ snapshots: '@floating-ui/utils': 0.2.11 optional: true + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.7.6': dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 optional: true + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@floating-ui/utils@0.2.11': optional: true + '@floating-ui/utils@0.2.12': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -8574,6 +8667,8 @@ snapshots: clone@1.0.4: optional: true + clsx@2.1.1: {} + codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.20.1 @@ -9102,6 +9197,8 @@ snapshots: entities@7.0.1: {} + entities@8.1.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -10045,6 +10142,10 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + linkifyjs@4.3.2: {} listenercount@1.0.1: {} @@ -10137,6 +10238,25 @@ snapshots: make-error@1.3.6: {} + markdown-it-container@4.0.0: {} + + markdown-it-footnote@4.0.0: {} + + markdown-it-ins@4.0.0: {} + + markdown-it-mark@4.0.0: {} + + markdown-it-sup@2.0.0: {} + + markdown-it-task-checkbox@1.0.6: {} + + markdown-it-ts@1.1.2: + dependencies: + entities: 8.1.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -10150,6 +10270,20 @@ snapshots: marked@16.4.2: {} + markstream-core@2.0.13: {} + + markstream-react@2.0.13(katex@0.16.28)(mermaid@11.12.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@floating-ui/dom': 1.8.0 + clsx: 2.1.1 + markstream-core: 2.0.13 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + stream-markdown-parser: 1.2.16 + optionalDependencies: + katex: 0.16.28 + mermaid: 11.12.2 + marky@1.3.0: {} mdast-util-find-and-replace@3.0.2: @@ -10321,6 +10455,8 @@ snapshots: mdurl@2.0.0: {} + mdurl@2.1.0: {} + mermaid@11.12.2: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -11551,6 +11687,16 @@ snapshots: stream-buffers@3.0.3: {} + stream-markdown-parser@1.2.16: + dependencies: + markdown-it-container: 4.0.0 + markdown-it-footnote: 4.0.0 + markdown-it-ins: 4.0.0 + markdown-it-mark: 4.0.0 + markdown-it-sup: 2.0.0 + markdown-it-task-checkbox: 1.0.6 + markdown-it-ts: 1.1.2 + stream-shift@1.0.3: {} streamx@2.23.0: @@ -11800,6 +11946,8 @@ snapshots: uc.micro@2.1.0: {} + uc.micro@3.0.0: {} + ufo@1.6.3: {} unbzip2-stream@1.4.3: diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 635bf97c59..8aae39c17a 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -64,6 +64,7 @@ "jszip": "^3.10.1", "katex": "^0.16.25", "lucide-react": "^0.541.0", + "markstream-react": "2.0.13", "mermaid": "^11.10.1", "modern-screenshot": "^4.7.0", "monaco-editor": "^0.52.2", @@ -89,6 +90,7 @@ "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "semver": "7.7.4", + "stream-markdown-parser": "1.2.16", "tabbable": "^6.5.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx index e0ffd1bed1..dc8e9e1aed 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx @@ -45,7 +45,7 @@ vi.mock('./useToolCardHeightContract', () => ({ })); vi.mock('@/infrastructure/markdown', () => ({ - MarkdownRenderer: ({ content }: { content: string }) => { + ThinkingMarkdownRenderer: ({ content }: { content: string }) => { markdownRender(content); return
{content}
; }, diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index 33eeb2a51b..3bdb348fcf 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -25,7 +25,7 @@ import { noteTailFollowStep, } from '@/infrastructure/diagnostics/flowChatTailFollowDiagnostics'; import { latestReasoningSummaryPreview } from '../utils/reasoningSummaryPresentation'; -import { MarkdownRenderer } from '@/infrastructure/markdown'; +import { ThinkingMarkdownRenderer } from '@/infrastructure/markdown'; import './ModelThinkingDisplay.scss'; interface ModelThinkingDisplayProps { @@ -455,7 +455,7 @@ export const ModelThinkingDisplay: React.FC = ({ onTouchEnd={handleContentTouchEnd} onKeyDown={handleContentKeyDown} > - ({ Icon: () => null, })); vi.mock('@/infrastructure/markdown', () => ({ - MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, + ThinkingMarkdownRenderer: ({ content }: { content: string }) =>
{content}
, })); vi.mock('./useToolCardHeightContract', () => ({ useToolCardHeightContract: () => ({ diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx index fb09897777..b0c6dd53da 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx @@ -21,6 +21,7 @@ interface MarkdownMathRendererProps { remarkAutolinkComputerFileLinks: Pluggable; urlTransform: (value: string) => string; sourceRange?: MarkdownSourceRange; + inline?: boolean; } export const MarkdownMathRenderer: React.FC = ({ @@ -31,8 +32,9 @@ export const MarkdownMathRenderer: React.FC = ({ remarkAutolinkComputerFileLinks, urlTransform, sourceRange, -}) => ( -
+ inline = false, +}) => { + const content = ( = ({ > {markdownContent} -
-); + ); + return inline + ? {content} + :
{content}
; +}; export default MarkdownMathRenderer; diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx index 8bdac47b04..4c30769aae 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MenuItem } from '@/shared/context-menu-system/types'; -import { MarkdownRenderer } from './MarkdownRenderer'; +import { MarkdownRenderer, ThinkingMarkdownRenderer } from './MarkdownRenderer'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores/canvasStore'; import { useSceneStore } from '@/app/stores/sceneStore'; import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ openFileInBestTarget: vi.fn(), openHtmlFileInExternalBrowser: vi.fn(), renderMath: vi.fn(), + renderHighlighter: vi.fn(), showContextMenu: vi.fn(), })); @@ -68,7 +69,10 @@ vi.mock('./MarkdownMathRenderer', () => ({ })); vi.mock('./AsyncPrismSyntaxHighlighter', () => ({ - AsyncPrismSyntaxHighlighter: ({ children }: { children: React.ReactNode }) =>
{children}
, + AsyncPrismSyntaxHighlighter: ({ children, preferFallback }: { children: React.ReactNode; preferFallback?: boolean }) => { + mocks.renderHighlighter(); + return
{children}
; + }, })); vi.mock('@/shared/context-menu-system/core/ContextMenuController', () => ({ @@ -168,6 +172,7 @@ describe('Markdown file links', () => { mocks.openFileInBestTarget.mockReset(); mocks.openHtmlFileInExternalBrowser.mockReset(); mocks.renderMath.mockReset(); + mocks.renderHighlighter.mockReset(); mocks.showContextMenu.mockReset(); mocks.getCurrentWorkspacePath.mockResolvedValue(EXAMPLE_WORKSPACE); mocks.readFileContent.mockResolvedValue('cmVsdS1wbmc='); @@ -185,6 +190,131 @@ describe('Markdown file links', () => { vi.clearAllMocks(); }); + it.each([ + { name: 'thinking', Renderer: ThinkingMarkdownRenderer, scans: false }, + { name: 'response', Renderer: MarkdownRenderer, scans: true }, + ])('runs arrival DOM scanning only for response Markdown: $name', async ({ Renderer, scans }) => { + await import('./ThinkingMarkdown'); + const scan = vi.spyOn(document, 'createTreeWalker'); + try { + await act(async () => root.render()); + await act(async () => root.render()); + await act(async () => root.render()); + const markdownRoot = container.querySelector('.markdown-renderer'); + expect(markdownRoot?.textContent).toBe('First appended'); + expect(scan.mock.calls.some(([node]) => node === markdownRoot)).toBe(scans); + } finally { + scan.mockRestore(); + } + }); + + it('preserves thinking code controls and image identity across stream completion', async () => { + // Resolve the lazy entry before asserting its product DOM. + await import('./ThinkingMarkdown'); + const content = '![Thinking preview](thinking-preview.png)\n\n```ts\nconst value = 1;\n'; + await act(async () => root.render()); + const image = container.querySelector('img'); + const toolbar = container.querySelector('.code-block-toolbar'); + expect(image).not.toBeNull(); + expect(toolbar?.querySelector('button')).not.toBeNull(); + expect(container.querySelector('.code-block-wrapper')?.getAttribute('data-openbitfun-state')).toBe('streaming'); + const lightweight = container.querySelector('pre.code-block-fallback'); + expect(lightweight).not.toBeNull(); + await act(async () => root.render()); + expect(container.querySelector('img')).toBe(image); + expect(container.querySelector('.code-block-toolbar')).toBe(toolbar); + expect(container.querySelector('.code-block-wrapper')?.hasAttribute('data-openbitfun-state')).toBe(false); + expect(container.querySelector('pre')).toBe(lightweight); + expect(container.querySelector('pre code > span:last-child')?.textContent).toBe('const value = 1;'); + expect(mocks.renderHighlighter).not.toHaveBeenCalled(); + expect(mocks.readFileContent).toHaveBeenCalledTimes(1); + }); + + it('keeps completed 100K thinking code lightweight after remounting', async () => { + const code = 'const value = 1;\n'.repeat(6500).trimEnd(); + const content = `\`\`\`ts\n${code}\n\`\`\``; + for (let mount = 0; mount < 2; mount++) { + await act(async () => root.render()); + expect(container.querySelector('pre.code-block-fallback')).not.toBeNull(); + expect(container.querySelector('pre code > span:last-child')?.textContent).toBe(code); + expect(container.querySelectorAll('pre code span')).toHaveLength(2); + expect(container.querySelector('.code-block-toolbar button')).not.toBeNull(); + expect(mocks.renderHighlighter).not.toHaveBeenCalled(); + await act(async () => root.render(null)); + } + }); + + it('keeps response code on the highlighter path after streaming', async () => { + const content = '```ts\nconst value = 1;\n```'; + await act(async () => root.render()); + expect(container.querySelector('pre[data-fallback]')?.getAttribute('data-fallback')).toBe('true'); + await act(async () => root.render()); + expect(container.querySelector('pre[data-fallback]')?.getAttribute('data-fallback')).toBe('false'); + expect(mocks.renderHighlighter).toHaveBeenCalled(); + }); + + it('keeps thinking file navigation and latest HTTP callbacks', async () => { + const firstClick = vi.fn(); + const latestClick = vi.fn(() => true); + const content = '[Source](computer:///srv/project/main.ts#L12) [Web](https://example.com)'; + await act(async () => root.render()); + await act(async () => root.render()); + act(() => container.querySelector('.file-link')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))); + expect(onFileViewRequest).toHaveBeenCalledWith('/srv/project/main.ts', 'main.ts', { start: 12, end: undefined }); + act(() => container.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))); + expect(latestClick).toHaveBeenCalledTimes(1); + expect(firstClick).not.toHaveBeenCalled(); + }); + + it.each(['', 'd2', 'infographic'])('keeps thinking fences on the product code renderer: %s', async language => { + await act(async () => root.render()); + expect(container.querySelector('.code-block-toolbar button')).not.toBeNull(); + expect(container.querySelector('pre code > span:last-child')?.textContent).toBe('one line'); + }); + + it('shows thinking Mermaid as lightweight source while preserving response diagrams', async () => { + await act(async () => root.render(B\n```'} />)); + expect(container.querySelector('[data-testid="mermaid-block"]')).toBeNull(); + expect(container.querySelector('pre code > span:last-child')?.textContent).toBe('graph TD; A-->B'); + expect(mocks.renderHighlighter).not.toHaveBeenCalled(); + await act(async () => root.render(B\n```'} />)); + expect(container.querySelector('[data-testid="mermaid-block"]')).not.toBeNull(); + }); + + it('sanitizes thinking HTML and keeps inline HTML inside its paragraph', async () => { + await act(async () => root.render(bold tail\n\n
More
'} />)); + expect(container.querySelector('p b')?.textContent).toBe('bold'); + expect(container.querySelector('details summary')?.textContent).toBe('More'); + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('[onerror]')).toBeNull(); + expect(container.querySelector('p p')).toBeNull(); + }); + + it('routes thinking file images through the owning remote reader', async () => { + await act(async () => root.render()); + expect(container.innerHTML).toContain(' { + const content = '![Owned thinking](owned-thinking.png)\n\nTail'; + await act(async () => root.render()); + expect(mocks.readFileContent).toHaveBeenCalledWith('/srv/first/owned-thinking.png', 'base64', 'first-host'); + mocks.readFileContent.mockResolvedValueOnce('bmV3'); + await act(async () => root.render()); + expect(mocks.readFileContent).toHaveBeenCalledWith('/srv/second/owned-thinking.png', 'base64', 'second-host'); + expect(container.querySelector('img')?.src).toBe('data:image/png;base64,bmV3'); + }); + + it('keeps callback-only thinking images away from the controller filesystem', async () => { + await act(async () => root.render()); + expect(mocks.readFileContent).not.toHaveBeenCalled(); + expect(mocks.getCurrentWorkspacePath).not.toHaveBeenCalled(); + expect(container.querySelector('img')).toBeNull(); + expect(container.textContent).toContain('components:markdown.remoteImageUnavailable'); + }); + it('preserves same-tag sibling matches for generated and raw HTML content', async () => { const content = `First paragraph. diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx index a426ad4b64..c74adbf615 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx @@ -48,6 +48,8 @@ const CANVAS_LINK_PREFIX = 'openbitfun-canvas://'; const WORKSPACE_FOLDER_PLACEHOLDER = '{{workspaceFolder}}'; const MarkdownMathRenderer = React.lazy(() => import('./MarkdownMathRenderer')); +const ThinkingMarkdown = React.lazy(() => import('./ThinkingMarkdown')); +const InlineFragment = ({ children }: { children?: ReactNode }) => <>{children}; function markdownUrlTransform(value: string, key?: string): string { if (/^openbitfun:\/\/(?:runtime|current-session)\//.test(value)) return value; @@ -795,8 +797,8 @@ export interface FlowCodeBlockFallbackProps { } /** - * Lightweight, stable line-numbered code renderer used while the surrounding - * markdown is still streaming. Its layout deliberately matches the + * Lightweight, stable line-numbered code renderer used for thinking at all + * times, and for response Markdown while streaming. Its layout matches the * `react-syntax-highlighter` `showLineNumbers` output: a fixed-width inline * line-number column followed by the line content, separated visually by the * same padding. This keeps the code block from visibly jumping when streaming @@ -915,7 +917,8 @@ function useLiveValueRef(value: T): React.MutableRefObject { return ref; } -export const MarkdownRenderer = React.memo(({ +const MarkdownSurface = React.memo(({ + thinking = false, content, sourceRange, workspaceId, @@ -990,7 +993,7 @@ export const MarkdownRenderer = React.memo(({ // which unmounts/remounts the code block and shifts its position every // tick. Append a synthetic closing fence so the AST stays a stable code // block from the moment the opening fence appears. - if (isStreaming) { + if (isStreaming && !thinking) { const fenceMatches = body.match(/^[ \t]{0,3}(`{3,}|~{3,})/gm); if (fenceMatches && fenceMatches.length % 2 === 1) { const lastFence = fenceMatches[fenceMatches.length - 1].trim(); @@ -1000,7 +1003,7 @@ export const MarkdownRenderer = React.memo(({ } return body; - }, [contentStr, isStreaming]); + }, [contentStr, isStreaming, thinking]); const markdownContentRef = useLiveValueRef(markdownContent); const needsWorkspacePathForLinks = useMemo( @@ -1329,7 +1332,7 @@ export const MarkdownRenderer = React.memo(({ const streaming = isStreamingRef.current; - if (language.toLowerCase().startsWith('mermaid')) { + if (!thinking && language.toLowerCase().startsWith('mermaid')) { return ( (({
- {/* - Always mount AsyncPrismSyntaxHighlighter. While streaming, - preferFallback keeps the lightweight line-numbered pre so we do - not remount Fallback ↔ Prism when the turn finishes (that remount - flashed the chat pane). - */} - - {code} - + {/* Thinking must never mount a syntax highlighter, even after completion + or reopening. Bulk highlighting caused measured completion stalls; + keep this lightweight path independent of streaming state. */} + {thinking ? ( + + ) : ( + + {code} + + )}
); @@ -1732,6 +1742,7 @@ export const MarkdownRenderer = React.memo(({ ); } }), [ + thinking, onFileDownloadRef, onImageReadRef, onImagePreviewRef, @@ -1759,9 +1770,45 @@ export const MarkdownRenderer = React.memo(({ ]); const textRevealRef = useRef(null); - useStreamingTextReveal(textRevealRef, sourceRange ? contentStr.slice(sourceRange.start, sourceRange.end) : contentStr, isStreaming); + // Do not re-enable arrival fading for thinking: its full-document DOM scan + // caused measured frame drops on long streams. This guard also applies to + // completed/remounted thinking; typewriter text advancement is independent. + useStreamingTextReveal(textRevealRef, sourceRange ? contentStr.slice(sourceRange.start, sourceRange.end) : contentStr, isStreaming, !thinking); const wrapperClassName = `markdown-renderer ${className}`.trim(); + const thinkingEnvironment = useMemo(() => ({ + fileAccess, surfaceScope, currentWorkspacePath, workspaceId, basePath, + remoteConnectionId, remoteSshHost, onImageRead, onFileDownload, + fileActionsViaCallbackOnly, expandDetailsByDefault, + }), [fileAccess, surfaceScope, currentWorkspacePath, workspaceId, basePath, + remoteConnectionId, remoteSshHost, onImageRead, onFileDownload, + fileActionsViaCallbackOnly, expandDetailsByDefault]); + // Rare HTML/math fragments retain the existing sanitizer and product renderers. + // Ordinary thinking text never enters the full-document remark/rehype pipeline. + const renderThinkingFragment = useCallback((fragment: string, inline: boolean, math: boolean) => { + const fragmentComponents = inline ? { ...components, p: InlineFragment } : components; + const basicFragment = ( + {fragment} + ); + return math ? ( + + + + ) : basicFragment; + }, [components, isStreaming]); const basicMarkdownRenderer = ( (({ /> )} - {shouldUseMathRenderer ? ( + {thinking ? ( + + + + ) : shouldUseMathRenderer ? ( (({ ); }); + +export const MarkdownRenderer = React.memo(props => ); + +/** Deliberately opt in only from the thinking surface, never from response bodies. */ +export const ThinkingMarkdownRenderer = React.memo>(props => ); diff --git a/src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.test.tsx b/src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.test.tsx new file mode 100644 index 0000000000..2c6bd31d52 --- /dev/null +++ b/src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Components } from 'react-markdown'; +import { defaultUrlTransform } from 'react-markdown'; +import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'; +import ThinkingMarkdown from './ThinkingMarkdown'; + +describe('thinking streaming renderer', () => { + let root: Root; + let container: HTMLDivElement; + const renderParagraph = vi.fn(); + const renderCode = vi.fn(); + const components: Components = { + p: ({ children }) => { renderParagraph(children); return

{children}

; }, + code: ({ children, className }) => { renderCode(); return {children}; }, + }; + const urlTransform = (value: string) => /^file:/.test(value) ? value : defaultUrlTransform(value); + const renderFragment = vi.fn((content: string) => {content}); + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + root = createRoot(container); + vi.clearAllMocks(); + }); + afterEach(() => act(() => root.unmount())); + + async function render(content: string, isStreaming = true) { + await act(async () => root.render()); + } + + it('skips rendering settled blocks while a 100K tail grows and preserves DOM on completion', async () => { + const prefix = 'Settled **paragraph**.\n\n'; + await render(prefix + 'Growing'); + const settled = container.querySelector('p'); + renderParagraph.mockClear(); + const tail = 'Growing' + 'long thinking '.repeat(8000); + await render(prefix + tail); + expect(container.textContent?.endsWith(tail.trimEnd())).toBe(true); + expect(container.querySelector('p')).toBe(settled); + expect(renderParagraph).toHaveBeenCalledTimes(1); + await render(prefix + tail, false); + expect(container.querySelector('p')).toBe(settled); + expect(renderFragment).not.toHaveBeenCalled(); + }); + + it('keeps incomplete long fenced code mounted through closure and completion', async () => { + const code = 'const value = 1;\n'.repeat(6500); + await render('```ts\n' + code); + const element = container.querySelector('code'); + expect(element?.textContent).toContain(code.trimEnd()); + await render('```ts\n' + code + '```', false); + expect(container.querySelector('code')).toBe(element); + expect(element?.className).toBe('language-ts'); + expect(renderFragment).not.toHaveBeenCalled(); + }); + + it('skips settled paragraphs and code even when the parser stops reusing nodes', async () => { + const code = 'const value = 1;\n'.repeat(80); + const prefix = 'Settled **paragraph**.\n\n' + (`\`\`\`ts\n${code}\`\`\`\n\n`).repeat(80) + + '[Link][ref]\n\n[ref]: https://example.com\n\n'; + // Reference definitions force the real parser out of its structured reuse path. + const parser = getMarkdown('thinking-reuse-regression'); + const options = { final: false, streamParse: 'auto' as const, reuseStableTopLevelNodes: true }; + const before = parseMarkdownToStructure(prefix + 'Growing', parser, options); + const after = parseMarkdownToStructure(prefix + 'Growing tail', parser, options); + expect(after[0]).not.toBe(before[0]); + expect(after[0]).toEqual(before[0]); + expect(prefix.length).toBeGreaterThan(100_000); + + await render(prefix + 'Growing'); + const settledCode = container.querySelector('code'); + renderParagraph.mockClear(); + renderCode.mockClear(); + await render(prefix + 'Growing tail'); + expect(container.textContent?.endsWith('Growing tail')).toBe(true); + expect(container.querySelector('code')).toBe(settledCode); + expect(renderCode).not.toHaveBeenCalled(); + expect(renderParagraph).toHaveBeenCalledTimes(1); + }); + + it('updates unchanged paragraph text when later reference definitions resolve links and images', async () => { + const prefix = '[Link][ref] and ![Image][image]\n\n'; + await render(prefix + 'Tail'); + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('img')?.getAttribute('src') ?? '').toBe(''); + await render(prefix + 'Tail\n\n[ref]: https://example.com/first "First"\n[image]: https://example.com/first.png\n'); + expect(container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/first'); + expect(container.querySelector('a')?.title).toBe('First'); + expect(container.querySelector('img')?.getAttribute('src')).toBe('https://example.com/first.png'); + await render(prefix + 'Tail\n\n[ref]: https://example.com/second "Second"\n[image]: https://example.com/second.png\n'); + expect(container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/second'); + expect(container.querySelector('a')?.title).toBe('Second'); + expect(container.querySelector('img')?.getAttribute('src')).toBe('https://example.com/second.png'); + }); + + it('resets incremental state when content is replaced or rewound', async () => { + await render('Old prefix.\n\nOld tail'); + await render('Replacement'); + expect(container.textContent).toBe('Replacement'); + await render('Replace'); + expect(container.textContent).toBe('Replace'); + }); + + it('refreshes reused blocks when their resource environment or final state changes', async () => { + const content = 'Settled paragraph.\n\nTail'; + const firstEnvironment = {}; + const secondEnvironment = {}; + const renderWithEnvironment = async (environment: object, isStreaming = true) => act(async () => root.render( + , + )); + await renderWithEnvironment(firstEnvironment); + renderParagraph.mockClear(); + await renderWithEnvironment(firstEnvironment); + expect(renderParagraph).not.toHaveBeenCalled(); + await renderWithEnvironment(secondEnvironment); + expect(renderParagraph).toHaveBeenCalledTimes(2); + renderParagraph.mockClear(); + await renderWithEnvironment(secondEnvironment, false); + expect(renderParagraph).toHaveBeenCalledTimes(2); + }); + + it('keeps parser state isolated between simultaneous thinking blocks', async () => { + const renderPair = async (first: string, second: string) => act(async () => root.render(<> + + + )); + await renderPair('First **thought**.\n\nTail', 'Second `thought`.\n\nOther'); + await renderPair('First **thought**.\n\nTail grows', 'Second `thought`.\n\nOther grows'); + expect(container.querySelector('strong')?.textContent).toBe('thought'); + expect(container.querySelector('code')?.textContent).toBe('thought'); + expect(container.textContent).toBe('First thought.Tail growsSecond thought.Other grows'); + }); + + it('renders nested lists and tables with complete content', async () => { + await render('- first\n - nested\n- [x] done\n\n| A | B |\n| :- | -: |\n| value | **bold** |', false); + expect(container.querySelector('li li')?.textContent).toBe('nested'); + expect(container.querySelector('input')?.disabled).toBe(true); + expect(container.querySelector('input')?.checked).toBe(true); + expect(container.querySelectorAll('th')).toHaveLength(2); + expect(container.querySelector('td strong')?.textContent).toBe('bold'); + }); + + it('does not activate incomplete or unsafe links', async () => { + await render('[partial](https://example.com'); + expect(container.textContent).toBe('partial'); + expect(container.querySelector('a')).toBeNull(); + await render('[partial](https://example.com) [bad](javascript:alert(1))', false); + expect(container.querySelector('a')?.href).toBe('https://example.com/'); + expect(container.querySelector('a[href^="javascript:"]')).toBeNull(); + }); + + it('passes only exceptional fragments through the existing safe pipeline', async () => { + await render('Normal **thinking** and $x+y$.\n\n
MoreContent
', false); + expect(renderFragment).toHaveBeenCalledWith('$x+y$', true, true); + expect(renderFragment).toHaveBeenCalledWith('
MoreContent
', false, false); + expect(renderFragment).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.tsx b/src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.tsx new file mode 100644 index 0000000000..0449fc0057 --- /dev/null +++ b/src/web-ui/src/infrastructure/markdown/ThinkingMarkdown.tsx @@ -0,0 +1,179 @@ +import React, { createContext, memo, useContext, useId, useLayoutEffect, useMemo, useRef } from 'react'; +import type { Components } from 'react-markdown'; +import { renderNode, type CustomComponentMap, type NodeComponentProps, type RenderContext } from 'markstream-react'; +import { getMarkdown, parseMarkdownToStructure, type BaseNode, type ParsedNode } from 'stream-markdown-parser'; +import { stabilizeThinkingNodes } from './stabilizeThinkingNodes'; + +interface ThinkingMarkdownProps { + content: string; + isStreaming: boolean; + isDark: boolean; + components: Components; + urlTransform: (value: string, key?: string) => string; + renderFragment: (content: string, inline: boolean, math: boolean) => React.ReactNode; + /** Changes to resource ownership must refresh memoized blocks even without new text. */ + environment?: object; +} + +type ProductRenderers = Pick; +const ProductContext = createContext(null); + +// This is a presentation adapter for the library's AST, not a second parser. +interface ProductNode extends BaseNode { + children?: ParsedNode[]; + content?: string; + language?: string; + level?: number; + ordered?: boolean; + start?: number; + items?: ParsedNode[]; + header?: ProductNode; + rows?: ProductNode[]; + cells?: ProductNode[]; + align?: string; + href?: string; + src?: string; + alt?: string; + title?: string | null; + checked?: boolean; + id?: string; +} + +type ThinkingContext = RenderContext & { insideLink?: boolean }; + +function ProductNodeRenderer({ node: sourceNode, ctx, indexKey }: NodeComponentProps) { + const product = useContext(ProductContext)!; + const node = sourceNode as ProductNode; + const context = ctx as ThinkingContext; + const key = String(indexKey); + const children = (nodes = node.children, childContext = context) => + nodes?.map((child, index) => renderNode(child, `${key}-${index}`, childContext)); + const element = (tag: keyof React.JSX.IntrinsicElements, props: Record = {}, body?: React.ReactNode) => { + const Component = (product.components[tag] || tag) as React.ElementType; + return React.createElement(Component, props, body); + }; + + switch (node.type) { + case 'text': + case 'text_special': { + const content = node.content ?? ''; + if (context.insideLink) return content; + // Match the existing internal-link autolinker without reparsing the paragraph. + const parts = content.split(/((?:computer:\/\/|file:\/\/|openbitfun-canvas:\/\/)[^\s<>()]+)/g); + return parts.map((part, index) => index % 2 + ? element('a', { key: index, href: product.urlTransform(part, 'href') }, part) + : part); + } + case 'paragraph': + // Parser 1.2.16 retains file links but rejects POSIX file:// image tokens. + // Keep those rare fragments on the existing image-capable safe pipeline. + return hasFileImage(node.raw) + ? product.renderFragment(node.raw, false, false) + : element('p', {}, children()); + case 'heading': return element(`h${Math.min(6, Math.max(1, node.level ?? 1))}` as 'h1', {}, children()); + case 'blockquote': return element('blockquote', {}, children()); + case 'strong': return element('strong', {}, children()); + case 'emphasis': return element('em', {}, children()); + case 'strikethrough': return element('del', {}, children()); + case 'inline': return children(); + case 'inline_code': return element('code', {}, node.code ?? ''); + case 'code_block': { + // An explicit language keeps even a one-line unlabelled fence a block. + // The shared renderer strips one final newline, as supplied by react-markdown. + const code = node.code ?? ''; + return element('code', { className: `language-${node.language || 'text'}` }, code.endsWith('\n') ? code : `${code}\n`); + } + case 'link': { + const body = children(node.children, { ...context, insideLink: true }); + const href = product.urlTransform(node.href ?? '', 'href'); + return node.loading || !href ? <>{body} : element('a', { href, title: node.title }, body); + } + case 'image': return element('img', { src: product.urlTransform(node.src ?? '', 'src'), alt: node.alt, title: node.title }); + case 'list': return element(node.ordered ? 'ol' : 'ul', { start: node.ordered ? node.start : undefined }, children(node.items)); + case 'list_item': return element('li', {}, children()); + case 'table': { + const row = (value: ProductNode, rowIndex: number, header: boolean) => element('tr', { key: rowIndex }, + value.cells?.map((cell, column) => element(header ? 'th' : 'td', { key: column, align: cell.align }, + hasFileImage(cell.raw) ? product.renderFragment(cell.raw, true, false) + : cell.children?.map((child, index) => renderNode(child, `${key}-${rowIndex}-${column}-${index}`, context))))); + return element('table', {}, <> + {node.header && row(node.header, -1, true)} + {node.rows?.map((value, index) => row(value, index, false))} + ); + } + case 'hardbreak': return
; + case 'softbreak': return '\n'; + case 'thematic_break': return
; + case 'checkbox': + case 'checkbox_input': return ; + case 'label_open': + case 'label_close': return null; + case 'html_inline': + case 'html_block': return product.renderFragment(node.content ?? node.raw, node.type === 'html_inline', false); + case 'math_inline': return product.renderFragment(`$${node.content ?? ''}$`, true, true); + case 'math_block': return product.renderFragment(`$$\n${node.content ?? ''}\n$$`, false, true); + case 'footnote_reference': return {node.id}; + case 'footnote': return
{children()}
; + case 'footnote_anchor': return null; + default: return node.raw; + } +} + +function hasFileImage(source: string): boolean { + return source.includes('![') && /file:/i.test(source); +} + +const customComponents: CustomComponentMap = Object.fromEntries([ + 'text', 'text_special', 'paragraph', 'heading', 'blockquote', 'strong', 'emphasis', + 'strikethrough', 'inline', 'inline_code', 'code_block', 'link', 'image', 'list', 'list_item', + 'table', 'hardbreak', 'softbreak', 'thematic_break', 'checkbox', 'checkbox_input', + 'label_open', 'label_close', 'html_inline', 'html_block', 'math_inline', 'math_block', + 'footnote', 'footnote_reference', 'footnote_anchor', + 'reference', 'highlight', 'insert', 'subscript', 'superscript', 'emoji', + // Keep specialized code languages on the same lightweight source path. + 'mermaid', 'd2', 'infographic', +].map(type => [type, ProductNodeRenderer])); + +const ThinkingBlock = memo(function ThinkingBlock({ node, index, context }: { + node: ParsedNode; + index: number; + context: RenderContext; +}) { + return renderNode(node, `${context.indexKey}-${index}`, context); +}); + +export default function ThinkingMarkdown({ content, isStreaming, isDark, components, urlTransform, renderFragment, environment }: ThinkingMarkdownProps) { + const id = useId(); + const parser = useMemo(() => getMarkdown(id, { + enableContainers: false, + enableFixIndentedCodeBlock: false, + markdownItOptions: { typographer: false }, + }), [id]); + const committedNodes = useRef([]); + const nodes = useMemo(() => { + const parsed = parseMarkdownToStructure(content, parser, { + final: !isStreaming, streamParse: 'auto', reuseStableTopLevelNodes: true, + }); + return stabilizeThinkingNodes(parsed, committedNodes.current); + }, [content, isStreaming, parser]); + // Only retain the last committed tree; an abandoned render must not publish + // its cache. Resource/theme/final-state updates still flow through contexts. + useLayoutEffect(() => { committedNodes.current = nodes; }, [nodes]); + const context = useMemo(() => ({ + indexKey: id, final: !isStreaming, isDark, customComponents, + // Thinking performance invariant: never enable arrival fading or syntax + // highlighting here, including completed/remounted thinking. Full-document + // reveal scans and bulk code highlighting caused long-stream/completion stalls. + // Keep code fences on the product's lightweight source renderer. + typewriter: false, fade: false, showTooltips: false, renderCodeBlocksAsPre: true, events: {}, + }), [id, isStreaming, isDark]); + const product = useMemo(() => ({ components, urlTransform, renderFragment, environment }), [components, urlTransform, renderFragment, environment]); + + // Use Markstream's public node renderer directly: no extra wrapper, scheduler, + // viewport virtualization, CSS theme, or global custom-component registrations. + // The parser owns incremental parsing; adapter stabilization lets memo skip + // settled blocks even when the parser cannot reuse its structured nodes. + return + {nodes.map((node, index) => )} + ; +} diff --git a/src/web-ui/src/infrastructure/markdown/index.ts b/src/web-ui/src/infrastructure/markdown/index.ts index ba5017fb4f..044258aaca 100644 --- a/src/web-ui/src/infrastructure/markdown/index.ts +++ b/src/web-ui/src/infrastructure/markdown/index.ts @@ -1,4 +1,4 @@ -export { MarkdownRenderer } from './MarkdownRenderer'; +export { MarkdownRenderer, ThinkingMarkdownRenderer } from './MarkdownRenderer'; export type { MarkdownRendererProps, MarkdownTraceContext, diff --git a/src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.test.ts b/src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.test.ts new file mode 100644 index 0000000000..84ba23bb83 --- /dev/null +++ b/src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import type { ParsedNode, TableNode } from 'stream-markdown-parser'; +import { stabilizeThinkingNodes } from './stabilizeThinkingNodes'; + +describe('thinking node stabilization', () => { + const code: ParsedNode = { + type: 'code_block', raw: '```ts\nvalue', code: 'value', language: 'ts', loading: true, + }; + + it('retains equal trees without mutating parser output and drops removed nodes', () => { + const previous: ParsedNode[] = [code, { type: 'paragraph', raw: 'tail', children: [] }]; + const parsed = structuredClone(previous); + expect(stabilizeThinkingNodes(parsed, previous)).toBe(previous); + expect(parsed[0]).not.toBe(code); + const shortened = stabilizeThinkingNodes([structuredClone(code)], previous); + expect(shortened).toHaveLength(1); + expect(shortened[0]).toBe(code); + expect(stabilizeThinkingNodes([], previous)).toEqual([]); + }); + + it.each([ + { loading: false }, + { language: 'js' }, + { code: 'different' }, + { diff: true }, + { sourceMap: { startLine: 3, endLine: 5 } }, + ])('does not freeze code state when raw text is unchanged: %j', change => { + const next = { ...code, ...change } as ParsedNode; + expect(stabilizeThinkingNodes([next], [code])[0]).toBe(next); + }); + + it('compares nested table state even when the table raw text is unchanged', () => { + const previous: TableNode = { + type: 'table', raw: '| link |', header: { type: 'table_row', raw: '', cells: [] }, + rows: [{ type: 'table_row', raw: '', cells: [{ + type: 'table_cell', raw: '[link][ref]', header: false, align: 'left', + children: [{ type: 'link', raw: '[link][ref]', href: '/first', title: null, text: 'link', children: [] }], + }] }], + }; + const next = structuredClone(previous); + next.rows[0].cells[0].align = 'right'; + expect(stabilizeThinkingNodes([next], [previous])[0]).toBe(next); + }); +}); diff --git a/src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.ts b/src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.ts new file mode 100644 index 0000000000..fe013eb3a8 --- /dev/null +++ b/src/web-ui/src/infrastructure/markdown/stabilizeThinkingNodes.ts @@ -0,0 +1,36 @@ +import type { ParsedNode } from 'stream-markdown-parser'; + +// Parser output is an acyclic tree of plain records and arrays. Compare the +// complete tree: identical raw Markdown can resolve to different links/images +// when a later reference definition arrives. Unknown non-plain values are not +// reusable unless they already share identity. +function sameParsedValue(previous: unknown, next: unknown): boolean { + if (previous === next) return true; + if (previous === null || next === null || typeof previous !== 'object' || typeof next !== 'object') return false; + if (Array.isArray(previous)) { + return Array.isArray(next) && previous.length === next.length + && previous.every((value, index) => sameParsedValue(value, next[index])); + } + if (Array.isArray(next)) return false; + const prototype = Object.getPrototypeOf(previous); + if (prototype !== Object.getPrototypeOf(next) || (prototype !== Object.prototype && prototype !== null)) return false; + const before = previous as Record; + const after = next as Record; + const keys = Object.keys(before); + return keys.length === Object.keys(after).length && keys.every(key => + Object.prototype.hasOwnProperty.call(after, key) && sameParsedValue(before[key], after[key])); +} + +/** Preserve settled block identities even when the parser falls back to fresh ASTs. */ +export function stabilizeThinkingNodes(next: ParsedNode[], previous: ParsedNode[]): ParsedNode[] { + if (!previous.length) return next; + let unchanged = next.length === previous.length; + const stabilized = next.map((node, index) => { + const old = previous[index]; + // Quickly reject the growing tail before walking its children. + if (old && old.type === node.type && old.raw === node.raw && sameParsedValue(old, node)) return old; + unchanged = false; + return node; + }); + return unchanged ? previous : stabilized; +} diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx index 82f5128203..5df37cc1ff 100644 --- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx +++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx @@ -17,9 +17,9 @@ function cleanup() { cleanups.splice(0).forEach(fn => fn()); } import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { STREAMING_TEXT_REVEAL_MS, useStreamingTextReveal } from './useStreamingTextReveal'; -function Fixture({ text, streaming = true }: { text: string; streaming?: boolean }) { +function Fixture({ text, streaming = true, enabled = true }: { text: string; streaming?: boolean; enabled?: boolean }) { const ref = useRef(null); - useStreamingTextReveal(ref, text, streaming); + useStreamingTextReveal(ref, text, streaming, enabled); return

{text}

; } let highlights: Map>; @@ -36,6 +36,31 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); describe('streaming text arrival paint', () => { + it('skips DOM scanning while disabled and clears active paint without replay on re-enable', () => { + const scan = vi.spyOn(document, 'createTreeWalker'); + try { + const view = render(); + view.rerender(); + expect(scan).not.toHaveBeenCalled(); + expect(visibleRanges()).toEqual([]); + view.rerender(); + expect(visibleRanges()).toEqual([]); + view.rerender(); + expect(visibleRanges()).toEqual(['C']); + scan.mockClear(); + view.rerender(); + expect(scan).not.toHaveBeenCalled(); + expect(highlights.size).toBe(0); + expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull(); + view.rerender(); + expect(visibleRanges()).toEqual([]); + view.rerender(); + expect(visibleRanges()).toEqual(['F']); + } finally { + scan.mockRestore(); + } + }); + it('fades only appended glyphs without replacing text nodes or wrapping spans', () => { const view = render(); const textNode = view.container.querySelector('p')!.firstChild; diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts index e241da7f5a..5985ecf827 100644 --- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts +++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts @@ -42,13 +42,13 @@ function readRuns(root: HTMLElement): { runs: TextRun[]; text: string } { } /** - * Paint-only arrival treatment, shared by every streaming Markdown surface. + * Paint-only arrival treatment for streaming Markdown surfaces that opt in. * Ranges leave React's text nodes, selection, wrapping and virtualizer geometry * untouched. Each owner removes only its own ranges from the shared buckets. * Existing text on mount (including virtualized remounts) is already settled. */ export function useStreamingTextReveal( - rootRef: RefObject, source: string, streaming: boolean, + rootRef: RefObject, source: string, streaming: boolean, enabled = true, ): void { const previous = useRef<{ source: string; text: string } | null>(null); const arrivals = useRef([]); @@ -93,6 +93,12 @@ export function useStreamingTextReveal( stopRef.current = stop; useLayoutEffect(() => { + if (!enabled) { + stopRef.current(); + arrivals.current = []; + previous.current = null; + return; + } const root = rootRef.current; if (!root) return; const view = root.ownerDocument.defaultView; @@ -184,9 +190,10 @@ export function useStreamingTextReveal( }; // Layout timing styles newly committed glyphs before their first paint. paint(view.performance.now()); - }, [source, streaming, rootRef]); + }, [source, streaming, rootRef, enabled]); useLayoutEffect(() => { + if (!enabled) return; const document = rootRef.current?.ownerDocument; const media = document?.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)'); const clear = () => { stopRef.current(); arrivals.current = []; }; @@ -199,5 +206,5 @@ export function useStreamingTextReveal( media?.removeEventListener?.('change', onPreference); document?.removeEventListener('visibilitychange', onVisibility); }; - }, [rootRef]); + }, [rootRef, enabled]); }