| author | |
| committer | |
| log | 2ad2d3e6f55d90da9107c9254423bbcaa9be4898 |
| tree | 20882a20be5a43d3a1d6ed8af0169835735c6782 |
| parent | b175d6b2a35f8b6869f981b96ceb20b2db979095 |
- extract `estimateTimelineMessageHeight` into `timelineHeight.ts`
- account for timeline width and explicit newlines when estimating wrapped lines
- re-measure virtualized rows on width changes to fix attachment/message height sizing
- add Vitest coverage for assistant/user wrapping and attachment row height rules3 files changed, 207 insertions(+), 16 deletions(-)
apps/web/src/components/ChatView.tsx+37-16| ... | @@ -131,6 +131,7 @@ import { | ... | @@ -131,6 +131,7 @@ import { |
| 131 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 131 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 132 | import { clamp } from "effect/Number"; | 132 | import { clamp } from "effect/Number"; |
| 133 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | 133 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 134 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | ||
| 134 | 135 | ||
| 135 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 136 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 136 | if (!duration) return formatTimestamp(createdAt); | 137 | if (!duration) return formatTimestamp(createdAt); |
| ... | @@ -3166,20 +3167,6 @@ type TimelineRow = | ... | @@ -3166,20 +3167,6 @@ type TimelineRow = |
| 3166 | } | 3167 | } |
| 3167 | | { kind: "working"; id: string; createdAt: string | null }; | 3168 | | { kind: "working"; id: string; createdAt: string | null }; |
| 3168 | 3169 | ||
| 3169 | function estimateTimelineMessageHeight(message: TimelineMessage): number { | ||
| 3170 | const textLength = message.text.length; | ||
| 3171 | if (message.role === "assistant") { | ||
| 3172 | const estimatedLines = Math.max(1, Math.ceil(textLength / 72)); | ||
| 3173 | return 78 + Math.min(estimatedLines * 22, 820); | ||
| 3174 | } | ||
| 3175 | |||
| 3176 | const estimatedLines = Math.max(1, Math.ceil(textLength / 56)); | ||
| 3177 | const attachmentCount = message.attachments?.length ?? 0; | ||
| 3178 | const attachmentRows = Math.ceil(attachmentCount / 2); | ||
| 3179 | const attachmentHeight = attachmentRows * 124; | ||
| 3180 | return 96 + Math.min(estimatedLines * 22, 620) + attachmentHeight; | ||
| 3181 | } | ||
| 3182 | |||
| 3183 | const MessagesTimeline = memo(function MessagesTimeline({ | 3170 | const MessagesTimeline = memo(function MessagesTimeline({ |
| 3184 | hasMessages, | 3171 | hasMessages, |
| 3185 | isWorking, | 3172 | isWorking, |
| ... | @@ -3200,6 +3187,36 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -3200,6 +3187,36 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3200 | onImageExpand, | 3187 | onImageExpand, |
| 3201 | markdownCwd, | 3188 | markdownCwd, |
| 3202 | }: MessagesTimelineProps) { | 3189 | }: MessagesTimelineProps) { |
| 3190 | const timelineRootRef = useRef<HTMLDivElement | null>(null); | ||
| 3191 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); | ||
| 3192 | |||
| 3193 | useLayoutEffect(() => { | ||
| 3194 | const timelineRoot = timelineRootRef.current; | ||
| 3195 | if (!timelineRoot) return; | ||
| 3196 | |||
| 3197 | const updateWidth = (nextWidth: number) => { | ||
| 3198 | setTimelineWidthPx((previousWidth) => { | ||
| 3199 | if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { | ||
| 3200 | return previousWidth; | ||
| 3201 | } | ||
| 3202 | return nextWidth; | ||
| 3203 | }); | ||
| 3204 | }; | ||
| 3205 | |||
| 3206 | updateWidth(timelineRoot.getBoundingClientRect().width); | ||
| 3207 | |||
| 3208 | if (typeof ResizeObserver === "undefined") return; | ||
| 3209 | const observer = new ResizeObserver((entries) => { | ||
| 3210 | const [entry] = entries; | ||
| 3211 | if (!entry) return; | ||
| 3212 | updateWidth(entry.contentRect.width); | ||
| 3213 | }); | ||
| 3214 | observer.observe(timelineRoot); | ||
| 3215 | return () => { | ||
| 3216 | observer.disconnect(); | ||
| 3217 | }; | ||
| 3218 | }, []); | ||
| 3219 | |||
| 3203 | const rows = useMemo<TimelineRow[]>(() => { | 3220 | const rows = useMemo<TimelineRow[]>(() => { |
| 3204 | const nextRows: TimelineRow[] = []; | 3221 | const nextRows: TimelineRow[] = []; |
| 3205 | 3222 | ||
| ... | @@ -3303,12 +3320,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -3303,12 +3320,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3303 | if (!row) return 96; | 3320 | if (!row) return 96; |
| 3304 | if (row.kind === "work") return 112; | 3321 | if (row.kind === "work") return 112; |
| 3305 | if (row.kind === "working") return 40; | 3322 | if (row.kind === "working") return 40; |
| 3306 | return estimateTimelineMessageHeight(row.message); | 3323 | return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); |
| 3307 | }, | 3324 | }, |
| 3308 | measureElement: measureVirtualElement, | 3325 | measureElement: measureVirtualElement, |
| 3309 | useAnimationFrameWithResizeObserver: true, | 3326 | useAnimationFrameWithResizeObserver: true, |
| 3310 | overscan: 8, | 3327 | overscan: 8, |
| 3311 | }); | 3328 | }); |
| 3329 | useEffect(() => { | ||
| 3330 | if (timelineWidthPx === null) return; | ||
| 3331 | rowVirtualizer.measure(); | ||
| 3332 | }, [rowVirtualizer, timelineWidthPx]); | ||
| 3312 | useEffect(() => { | 3333 | useEffect(() => { |
| 3313 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { | 3334 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { |
| 3314 | const viewportHeight = instance.scrollRect?.height ?? 0; | 3335 | const viewportHeight = instance.scrollRect?.height ?? 0; |
| ... | @@ -3624,7 +3645,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -3624,7 +3645,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3624 | } | 3645 | } |
| 3625 | 3646 | ||
| 3626 | return ( | 3647 | return ( |
| 3627 | <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"> | 3648 | <div ref={timelineRootRef} className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"> |
| 3628 | {virtualizedRowCount > 0 && ( | 3649 | {virtualizedRowCount > 0 && ( |
| 3629 | <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> | 3650 | <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> |
| 3630 | {virtualRows.map((virtualRow: VirtualItem) => { | 3651 | {virtualRows.map((virtualRow: VirtualItem) => { |
apps/web/src/components/timelineHeight.test.ts created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | ||
| 4 | |||
| 5 | describe("estimateTimelineMessageHeight", () => { | ||
| 6 | it("uses assistant sizing rules for assistant messages", () => { | ||
| 7 | expect( | ||
| 8 | estimateTimelineMessageHeight({ | ||
| 9 | role: "assistant", | ||
| 10 | text: "a".repeat(144), | ||
| 11 | }), | ||
| 12 | ).toBe(122); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("adds one attachment row for one or two user attachments", () => { | ||
| 16 | expect( | ||
| 17 | estimateTimelineMessageHeight({ | ||
| 18 | role: "user", | ||
| 19 | text: "hello", | ||
| 20 | attachments: [{ id: "1" }], | ||
| 21 | }), | ||
| 22 | ).toBe(346); | ||
| 23 | |||
| 24 | expect( | ||
| 25 | estimateTimelineMessageHeight({ | ||
| 26 | role: "user", | ||
| 27 | text: "hello", | ||
| 28 | attachments: [{ id: "1" }, { id: "2" }], | ||
| 29 | }), | ||
| 30 | ).toBe(346); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("adds a second attachment row for three or four user attachments", () => { | ||
| 34 | expect( | ||
| 35 | estimateTimelineMessageHeight({ | ||
| 36 | role: "user", | ||
| 37 | text: "hello", | ||
| 38 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], | ||
| 39 | }), | ||
| 40 | ).toBe(574); | ||
| 41 | |||
| 42 | expect( | ||
| 43 | estimateTimelineMessageHeight({ | ||
| 44 | role: "user", | ||
| 45 | text: "hello", | ||
| 46 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], | ||
| 47 | }), | ||
| 48 | ).toBe(574); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("does not cap long user message estimates", () => { | ||
| 52 | expect( | ||
| 53 | estimateTimelineMessageHeight({ | ||
| 54 | role: "user", | ||
| 55 | text: "a".repeat(56 * 120), | ||
| 56 | }), | ||
| 57 | ).toBe(2736); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("counts explicit newlines for user message estimates", () => { | ||
| 61 | expect( | ||
| 62 | estimateTimelineMessageHeight({ | ||
| 63 | role: "user", | ||
| 64 | text: "first\nsecond\nthird", | ||
| 65 | }), | ||
| 66 | ).toBe(162); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("uses narrower width to increase user line wrapping", () => { | ||
| 70 | const message = { | ||
| 71 | role: "user" as const, | ||
| 72 | text: "a".repeat(52), | ||
| 73 | }; | ||
| 74 | |||
| 75 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); | ||
| 76 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("uses narrower width to increase assistant line wrapping", () => { | ||
| 80 | const message = { | ||
| 81 | role: "assistant" as const, | ||
| 82 | text: "a".repeat(200), | ||
| 83 | }; | ||
| 84 | |||
| 85 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); | ||
| 86 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); | ||
| 87 | }); | ||
| 88 | }); | ||
apps/web/src/components/timelineHeight.ts created+82| ... | @@ -0,0 +1,82 @@ | ||
| 1 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; | ||
| 2 | const USER_CHARS_PER_LINE_FALLBACK = 56; | ||
| 3 | const LINE_HEIGHT_PX = 22; | ||
| 4 | const ASSISTANT_BASE_HEIGHT_PX = 78; | ||
| 5 | const USER_BASE_HEIGHT_PX = 96; | ||
| 6 | const ATTACHMENTS_PER_ROW = 2; | ||
| 7 | // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. | ||
| 8 | const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; | ||
| 9 | const USER_BUBBLE_WIDTH_RATIO = 0.8; | ||
| 10 | const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; | ||
| 11 | const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; | ||
| 12 | const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; | ||
| 13 | const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; | ||
| 14 | const MIN_USER_CHARS_PER_LINE = 16; | ||
| 15 | const MIN_ASSISTANT_CHARS_PER_LINE = 20; | ||
| 16 | |||
| 17 | interface TimelineMessageHeightInput { | ||
| 18 | role: "user" | "assistant" | "system"; | ||
| 19 | text: string; | ||
| 20 | attachments?: ReadonlyArray<{ id: string }>; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface TimelineHeightEstimateLayout { | ||
| 24 | timelineWidthPx: number | null; | ||
| 25 | } | ||
| 26 | |||
| 27 | function estimateWrappedLineCount(text: string, charsPerLine: number): number { | ||
| 28 | if (text.length === 0) return 1; | ||
| 29 | |||
| 30 | // Avoid allocating via split for long logs; iterate once and count wrapped lines. | ||
| 31 | let lines = 0; | ||
| 32 | let currentLineLength = 0; | ||
| 33 | for (let index = 0; index < text.length; index += 1) { | ||
| 34 | if (text.charCodeAt(index) === 10) { | ||
| 35 | lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); | ||
| 36 | currentLineLength = 0; | ||
| 37 | continue; | ||
| 38 | } | ||
| 39 | currentLineLength += 1; | ||
| 40 | } | ||
| 41 | |||
| 42 | lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); | ||
| 43 | return lines; | ||
| 44 | } | ||
| 45 | |||
| 46 | function isFinitePositiveNumber(value: number | null | undefined): value is number { | ||
| 47 | return typeof value === "number" && Number.isFinite(value) && value > 0; | ||
| 48 | } | ||
| 49 | |||
| 50 | function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { | ||
| 51 | if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; | ||
| 52 | const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; | ||
| 53 | const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); | ||
| 54 | return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); | ||
| 55 | } | ||
| 56 | |||
| 57 | function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { | ||
| 58 | if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; | ||
| 59 | const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); | ||
| 60 | return Math.max( | ||
| 61 | MIN_ASSISTANT_CHARS_PER_LINE, | ||
| 62 | Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | export function estimateTimelineMessageHeight( | ||
| 67 | message: TimelineMessageHeightInput, | ||
| 68 | layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, | ||
| 69 | ): number { | ||
| 70 | if (message.role !== "user") { | ||
| 71 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); | ||
| 72 | const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); | ||
| 73 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; | ||
| 74 | } | ||
| 75 | |||
| 76 | const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); | ||
| 77 | const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); | ||
| 78 | const attachmentCount = message.attachments?.length ?? 0; | ||
| 79 | const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); | ||
| 80 | const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; | ||
| 81 | return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; | ||
| 82 | } | ||