From f9f255e08ab57fab1d2d6b33dd0223f7a111978b Mon Sep 17 00:00:00 2001 From: limit_yan Date: Sat, 13 Jun 2026 18:43:43 +0800 Subject: [PATCH] fix(flow-chat): resolve diff card text truncation on long lines InlineDiffPreview used fixed-height virtualization rows (22px) while CSS allowed text wrapping (pre-wrap). Wrapped content beyond 22px was hidden by overflow:hidden, making long diff lines unreadable. Changes: - Switch virtualizer to dynamic measurement (measureElement) so wrapped rows report their real height instead of a fixed 22px estimate - Add ResizeObserver to invalidate cached measurements when the container width changes (wrapping depends on width) - Add content truncation for very large diffs (500 lines / 50k chars) with head+tail preview and a visible truncation notice - Remove overflow:hidden on diff-line__content; use overflow-wrap:anywhere - Apply measureElement ref to context-separator rows for measurement consistency --- .../components/InlineDiffPreview.scss | 12 +- .../components/InlineDiffPreview.tsx | 109 ++++++++++++++++-- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss b/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss index 055acdeb7..c379f1def 100644 --- a/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss +++ b/src/web-ui/src/flow_chat/components/InlineDiffPreview.scss @@ -51,6 +51,15 @@ color: var(--color-text-muted, #888); font-style: italic; } + + &__truncation-notice { + padding: 0.25rem 0.625rem; + font-size: var(--flowchat-font-size-xs); + color: var(--color-text-muted, #888); + background: var(--color-bg-tertiary, rgba(255, 255, 255, 0.03)); + border-bottom: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.06)); + font-style: italic; + } } .diff-line { @@ -187,8 +196,7 @@ flex: 1; padding: 0 8px; white-space: pre-wrap; - word-break: break-word; - overflow: hidden; + overflow-wrap: anywhere; // Strip global and SyntaxHighlighter borders/backgrounds. pre, code, span { diff --git a/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx b/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx index c69ed0dd2..d4a8f074f 100644 --- a/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx +++ b/src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx @@ -11,7 +11,7 @@ * 5. Row virtualization via @tanstack/react-virtual: only visible rows are in the DOM */ -import React, { useMemo, memo, useRef, useCallback, useState, CSSProperties } from 'react'; +import React, { useMemo, memo, useRef, useCallback, useState, useEffect, CSSProperties } from 'react'; import Prism from 'prismjs'; import { useVirtualizer } from '@tanstack/react-virtual'; import { diffLines, Change } from 'diff'; @@ -26,6 +26,61 @@ const log = createLogger('InlineDiffPreview'); /** Estimated row height in px — must match CSS line-height × font-size. */ const ROW_HEIGHT = 22; +/** + * Maximum total lines (original + modified) before content is truncated. + * Keeps diffLines() + Prism.tokenize() cost bounded for large files. + * The caller may also pre-truncate; this is a safety net inside the component. + */ +const MAX_TOTAL_LINES = 500; +/** Character budget that complements MAX_TOTAL_LINES for very long single lines. */ +const MAX_TOTAL_CHARS = 50_000; + +/** Result of truncation: possibly shortened content + metadata. */ +interface TruncationResult { + originalContent: string; + modifiedContent: string; + truncated: boolean; + omittedLines: number; +} + +/** + * Truncate original/modified content when combined line or char count exceeds + * thresholds. Returns head + tail so both ends of the diff remain visible. + */ +function truncateForDiff(original: string, modified: string): TruncationResult { + const origLines = original ? original.split('\n') : []; + const modLines = modified ? modified.split('\n') : []; + const totalLines = origLines.length + modLines.length; + const totalChars = original.length + modified.length; + + if (totalLines <= MAX_TOTAL_LINES && totalChars <= MAX_TOTAL_CHARS) { + return { originalContent: original, modifiedContent: modified, truncated: false, omittedLines: 0 }; + } + + // Budget per side, per half (head/tail). + const perSide = Math.max(50, Math.floor(MAX_TOTAL_LINES / 4)); + + const slice = (lines: string[]): { text: string; kept: number; dropped: number } => { + if (lines.length <= perSide * 2) return { text: lines.join('\n'), kept: lines.length, dropped: 0 }; + const head = lines.slice(0, perSide); + const tail = lines.slice(lines.length - perSide); + return { + text: [...head, '', `... truncated ${lines.length - perSide * 2} lines ...`, '', ...tail].join('\n'), + kept: perSide * 2, + dropped: lines.length - perSide * 2, + }; + }; + + const o = slice(origLines); + const m = slice(modLines); + return { + originalContent: o.text, + modifiedContent: m.text, + truncated: true, + omittedLines: o.dropped + m.dropped, + }; +} + export interface InlineDiffPreviewProps { /** Original content. */ originalContent: string; @@ -302,25 +357,31 @@ export const InlineDiffPreview: React.FC = memo(({ return 'text'; }, [language, filePath]); + // Truncate very large inputs before diff/tokenization to protect the main thread. + const truncated = useMemo( + () => truncateForDiff(originalContent, modifiedContent), + [originalContent, modifiedContent], + ); + // Compute diff line list (fast, O(ND)) const diffLineList = useMemo(() => { try { - const rawDiff = computeLineDiff(originalContent, modifiedContent); + const rawDiff = computeLineDiff(truncated.originalContent, truncated.modifiedContent); return applyContextCollapsing(rawDiff, contextLines); } catch (error) { log.error('Diff computation failed', error); return [{ type: 'context-separator' as const, content: 'Diff computation failed; file may be too large.' }]; } - }, [originalContent, modifiedContent, contextLines]); + }, [truncated.originalContent, truncated.modifiedContent, contextLines]); // Tokenize each content once — O(content_length), not O(lines²) const originalLineTokens = useMemo( - () => tokenizeContent(originalContent, detectedLanguage), - [originalContent, detectedLanguage], + () => tokenizeContent(truncated.originalContent, detectedLanguage), + [truncated.originalContent, detectedLanguage], ); const modifiedLineTokens = useMemo( - () => tokenizeContent(modifiedContent, detectedLanguage), - [modifiedContent, detectedLanguage], + () => tokenizeContent(truncated.modifiedContent, detectedLanguage), + [truncated.modifiedContent, detectedLanguage], ); // Build stylesheet from prism style for token coloring @@ -355,14 +416,35 @@ export const InlineDiffPreview: React.FC = memo(({ [originalLineTokens, modifiedLineTokens], ); - // Virtualizer + // Virtualizer with dynamic measurement so wrapped long lines get correct height. const virtualizer = useVirtualizer({ count: diffLineList.length, getScrollElement: () => containerRef.current, estimateSize: () => ROW_HEIGHT, overscan: 3, + measureElement: (el) => el.getBoundingClientRect().height, }); + // Re-measure all rows when the container width changes (wrapping may differ). + const [containerWidth, setContainerWidth] = useState(0); + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const w = Math.round(entry.contentRect.width); + setContainerWidth((prev) => (prev === w ? prev : w)); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + // When width changes, invalidate cached measurements so rows re-measure. + useEffect(() => { + virtualizer.measure(); + }, [containerWidth]); // eslint-disable-line react-hooks/exhaustive-deps + const handleLineClick = useCallback( (index: number, line: DiffLine) => { if (line.type === 'context-separator') return; @@ -389,6 +471,11 @@ export const InlineDiffPreview: React.FC = memo(({ return (
+ {truncated.truncated && ( +
+ Content too large; showing first and last portions ({truncated.omittedLines} lines omitted). +
+ )}
= memo(({ return (
@@ -436,14 +524,15 @@ export const InlineDiffPreview: React.FC = memo(({ return (
handleLineClick(virtualRow.index, line)} >