diff --git a/packages/ui/src/components/chat/DiffPreview.tsx b/packages/ui/src/components/chat/DiffPreview.tsx index c749b033..2c0fc05a 100644 --- a/packages/ui/src/components/chat/DiffPreview.tsx +++ b/packages/ui/src/components/chat/DiffPreview.tsx @@ -3,17 +3,29 @@ import { cn } from '@/lib/utils'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars'; -import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; +import { + useWorkerHighlightedLines, + type WorkerHighlightedLinesResult, +} from '@/components/code/useWorkerHighlightedLines'; import { parseDiffToUnified } from './message/toolRenderers'; -// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls -// back to plain text while loading or on failure. -const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) => - html !== undefined ? ( - - ) : ( - {content} - ); +// Keep the line's layout stable while a cold worker request finishes. Plain +// text appears only if highlighting fails, avoiding a visible color flash. +interface CodeLineContentProps { + content: string; + html: string | undefined; + status: WorkerHighlightedLinesResult['status']; +} + +const CodeLineContent: React.FC = ({ content, html, status }) => { + if (status === 'ready' && html !== undefined) { + return ; + } + if (status === 'loading') { + return {content}; + } + return {content}; +}; interface DiffPreviewProps { diff: string; @@ -44,7 +56,7 @@ export const DiffPreview: React.FC = ({ diff, filePath }) => {
{hunk.lines.map((line, lineIdx) => { - const html = highlighted?.[lineCursor]; + const html = highlighted.lines?.[lineCursor]; lineCursor += 1; return (
= ({ diff, filePath }) => { {line.lineNumber || ''}
- +
); @@ -106,7 +118,11 @@ export const WritePreview: React.FC = ({ content, filePath }) {lineIdx + 1}
- +
))} diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index ab5b6eb6..2feb270a 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -167,6 +167,12 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis return result?.type === 'highlightLines' ? result.lines : null; }; +/** Return an already-tokenized line result without scheduling a worker request. */ +export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => { + const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code)); + return cached?.type === 'highlightLines' ? cached.lines : null; +}; + /** * Tokenize `code` with the given resolved TextMate theme and return per-line * styled runs with offsets — for building CodeMirror decorations that match the diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index 5ef116d7..fcc29517 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -54,7 +54,8 @@ export const VirtualizedCodeBlock: React.FC = React.m const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); // Tokenize the whole block in one worker call; rows index into the result. const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]); - const highlighted = useWorkerHighlightedLines(fullText, language); + const highlightResult = useWorkerHighlightedLines(fullText, language); + const highlighted = highlightResult.lines; const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD; diff --git a/packages/ui/src/components/code/useWorkerHighlightedLines.ts b/packages/ui/src/components/code/useWorkerHighlightedLines.ts index 2a27e2ee..a9d20e60 100644 --- a/packages/ui/src/components/code/useWorkerHighlightedLines.ts +++ b/packages/ui/src/components/code/useWorkerHighlightedLines.ts @@ -1,26 +1,60 @@ import React from 'react'; -import { highlightLinesInWorker } from '@/components/chat/markdown/markdown-worker'; +import { + getCachedHighlightedLines, + highlightLinesInWorker, +} from '@/components/chat/markdown/markdown-worker'; // Tokenize a whole block ONCE in the Shiki worker and expose per-line inner // HTML. For per-line layouts (diffs, gutters, virtualization) that would -// otherwise spawn one highlighter per row. Returns `null` until the first -// result lands (or permanently on failure) — callers render plain text then. +// otherwise spawn one highlighter per row. Cached results are available on the +// first render; cold requests distinguish loading from permanent failure so +// callers can choose whether to reveal their plain-text fallback. // // Whole-block tokenization also restores cross-line syntax context (multi-line // strings / comments) that independent per-line highlighting loses. -export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => { - const [lines, setLines] = React.useState(null); +export type WorkerHighlightedLinesResult = + | { status: 'loading'; lines: null } + | { status: 'ready'; lines: string[] } + | { status: 'failed'; lines: null }; + +type HighlightState = WorkerHighlightedLinesResult & { + code: string; + language: string; +}; + +const getHighlightState = (code: string, language: string): HighlightState => { + const lines = getCachedHighlightedLines(code, language); + return lines + ? { status: 'ready', lines, code, language } + : { status: 'loading', lines: null, code, language }; +}; + +export const useWorkerHighlightedLines = (code: string, language: string): WorkerHighlightedLinesResult => { + const normalizedLanguage = (language || 'text').toLowerCase(); + const [state, setState] = React.useState(() => getHighlightState(code, normalizedLanguage)); React.useEffect(() => { + const cached = getCachedHighlightedLines(code, normalizedLanguage); + if (cached) { + setState({ status: 'ready', lines: cached, code, language: normalizedLanguage }); + return; + } + + setState({ status: 'loading', lines: null, code, language: normalizedLanguage }); let active = true; - setLines(null); - void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => { - if (active) setLines(result); + void highlightLinesInWorker(code, normalizedLanguage).then((lines) => { + if (!active) return; + setState(lines + ? { status: 'ready', lines, code, language: normalizedLanguage } + : { status: 'failed', lines: null, code, language: normalizedLanguage }); }); return () => { active = false; }; - }, [code, language]); + }, [code, normalizedLanguage]); - return lines; + if (state.code !== code || state.language !== normalizedLanguage) { + return getHighlightState(code, normalizedLanguage); + } + return state; };