| 1 | import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 2 | import { hastBlockToJsx } from "../lib/hastJsx"; |
| 3 | import { estimateHastBytes, markdownContentRevision, type MarkdownBlock, type MarkdownParseResult } from "../lib/markdownPipeline"; |
| 4 | import { getMarkdownWorkerClient } from "../lib/markdownWorkerClient"; |
| 5 | import { getTranscriptStore } from "../lib/transcriptStore"; |
| 6 | import { MARKDOWN_AST_ELEMENT_PAGE, visibleMarkdownBlockCount } from "../lib/markdownDomBudget"; |
| 7 | import { useT } from "../lib/i18n"; |
| 8 | import { createComponents } from "./markdownComponents"; |
| 9 | import { useChatFileCandidateReport } from "./ChatFileLinkContext"; |
| 10 | import { MarkdownSourceTable } from "./MarkdownTable"; |
| 11 | import "katex/dist/katex.min.css"; |
| 12 | import "./harness-chat/MarkdownText.css"; |
| 13 | |
| 14 | const Block = memo(function Block({ block, components }: { block: MarkdownBlock; components: ReturnType<typeof createComponents> }) { |
| 15 | return block.virtualTable ? <MarkdownSourceTable data={block.virtualTable} /> : hastBlockToJsx(block, components); |
| 16 | }); |
| 17 | |
| 18 | let nextWorkerDocumentId = 1; |
| 19 | |
| 20 | /** Worker parsing is independent of viewport geometry. Every block uses natural flow. */ |
| 21 | const MarkdownHistory = memo(function MarkdownHistory({ text, streaming = false, plainStatusBlocks = false, cacheKey, fallback, onParsed, onError }: { |
| 22 | text: string; streaming?: boolean; plainStatusBlocks?: boolean; cacheKey?: string; fallback: ReactNode; |
| 23 | onParsed?: () => void; onError?: () => void; |
| 24 | }) { |
| 25 | const t = useT(); |
| 26 | // The revision is a cache key for settled content only: a streaming body is |
| 27 | // never stored, so hashing it on every delta would be O(body) work per |
| 28 | // commit for a lookup that cannot hit. |
| 29 | const revision = useMemo(() => (streaming ? 0 : markdownContentRevision(text)), [streaming, text]); |
| 30 | const [parsed, setParsed] = useState<{ text: string; result: MarkdownParseResult }>(); |
| 31 | const previous = useRef<MarkdownParseResult | undefined>(undefined); |
| 32 | const [workerDocumentId] = useState(() => `markdown-${nextWorkerDocumentId++}`); |
| 33 | const root = useRef<HTMLDivElement>(null); |
| 34 | const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined"); |
| 35 | const [elementBudget, setElementBudget] = useState<number>(MARKDOWN_AST_ELEMENT_PAGE); |
| 36 | useEffect(() => { |
| 37 | if (visible || typeof IntersectionObserver === "undefined" || !root.current) return; |
| 38 | const observer = new IntersectionObserver(entries => { |
| 39 | if (entries.some(entry => entry.isIntersecting)) { setVisible(true); observer.disconnect(); } |
| 40 | }, { rootMargin: "800px" }); |
| 41 | observer.observe(root.current); |
| 42 | return () => observer.disconnect(); |
| 43 | }, [visible]); |
| 44 | const components = useMemo(() => createComponents(plainStatusBlocks), [plainStatusBlocks]); |
| 45 | const cached = useMemo( |
| 46 | () => (!streaming && cacheKey ? getTranscriptStore().getMarkdown(cacheKey, revision) : undefined), |
| 47 | [cacheKey, revision, streaming], |
| 48 | ); |
| 49 | useEffect(() => { |
| 50 | const client = getMarkdownWorkerClient(); |
| 51 | return () => client.releaseDocument(workerDocumentId); |
| 52 | }, [workerDocumentId]); |
| 53 | useEffect(() => { |
| 54 | if (!visible && !streaming) return; |
| 55 | if (cached?.blocks) { onParsed?.(); return; } |
| 56 | let cancelled = false; |
| 57 | const request = getMarkdownWorkerClient().parseDocument(workerDocumentId, text, { |
| 58 | final: !streaming, |
| 59 | priority: streaming ? "interactive" : visible ? "visible" : "background", |
| 60 | }); |
| 61 | void request.promise.then(result => { |
| 62 | if (cancelled || !result) return; |
| 63 | // Retain unchanged AST identities across stream publications and |
| 64 | // finalization, so React keeps native selection and code disclosure |
| 65 | // hosts. The comparison is the fingerprint the parse already computed: |
| 66 | // serializing both trees here cost O(blocks x block size) of string |
| 67 | // allocation on the main thread on every streamed commit. |
| 68 | const stable = previous.current?.blocks; |
| 69 | if (stable) result.blocks = result.blocks.map((block, index) => |
| 70 | stable[index]?.key === block.key && stable[index]?.fingerprint === block.fingerprint ? stable[index] : block); |
| 71 | previous.current = result; |
| 72 | setParsed({ text, result }); |
| 73 | if (cacheKey && !streaming) getTranscriptStore().setMarkdown(cacheKey, revision, { |
| 74 | source: text, blocks: result.blocks, selectionText: result.selectionText, |
| 75 | selectionRevision: result.selectionRevision, |
| 76 | bytes: text.length * 2 + result.selectionText.length * 2 + estimateHastBytes(result.blocks), |
| 77 | }); |
| 78 | onParsed?.(); |
| 79 | }).catch(() => { if (!cancelled) onError?.(); }); |
| 80 | return () => { cancelled = true; request.cancel(); }; |
| 81 | }, [cacheKey, cached, onError, onParsed, revision, streaming, text, visible, workerDocumentId]); |
| 82 | const result = cached?.blocks ? cached : parsed && (parsed.text === text || text.startsWith(parsed.text)) ? parsed.result : undefined; |
| 83 | // Only blocks the parser has already committed are reported, so a streaming |
| 84 | // answer never asks the host about a half-written path. |
| 85 | useChatFileCandidateReport(result?.blocks, revision); |
| 86 | const visibleBlockCount = result ? visibleMarkdownBlockCount(result.blocks, elementBudget) : 0; |
| 87 | const nodes = useMemo(() => result?.blocks.slice(0, visibleBlockCount) |
| 88 | .map(block => <Block key={block.key} block={block} components={components} />), [result, components, visibleBlockCount]); |
| 89 | const pending = !cached?.blocks && parsed && text.startsWith(parsed.text) ? text.slice(parsed.text.length) : ""; |
| 90 | const hiddenBlocks = (result?.blocks.length ?? 0) - visibleBlockCount; |
| 91 | return <div ref={root} className="md" data-markdown-blocks={result?.blocks.length} |
| 92 | data-markdown-visible-blocks={visibleBlockCount}> |
| 93 | {result ? <>{nodes}{hiddenBlocks > 0 && <button type="button" className="btn" |
| 94 | onClick={() => setElementBudget(value => value + MARKDOWN_AST_ELEMENT_PAGE)}> |
| 95 | {t("chat.loadMoreBlocks", { count: hiddenBlocks })} |
| 96 | </button>}{!hiddenBlocks && pending && <span style={{ whiteSpace: "pre-wrap" }}>{pending}</span>}</> : fallback} |
| 97 | </div>; |
| 98 | }); |
| 99 | export default MarkdownHistory; |
| 100 |