authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-04 11:53:19-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 10:20:34-08:00
log2ad2d3e6f55d90da9107c9254423bbcaa9be4898
tree20882a20be5a43d3a1d6ed8af0169835735c6782
parentb175d6b2a35f8b6869f981b96ceb20b2db979095

Make chat timeline height estimates width-aware and test them

- 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 rules

3 files changed, 207 insertions(+), 16 deletions(-)

apps/web/src/components/ChatView.tsx+37-16
......@@ -131,6 +131,7 @@ import {
131131import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
132132import { clamp } from "effect/Number";
133133import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
134import { estimateTimelineMessageHeight } from "./timelineHeight";
134135
135136function formatMessageMeta(createdAt: string, duration: string | null): string {
136137 if (!duration) return formatTimestamp(createdAt);
......@@ -3166,20 +3167,6 @@ type TimelineRow =
31663167 }
31673168 | { kind: "working"; id: string; createdAt: string | null };
31683169
3169function 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
31833170const MessagesTimeline = memo(function MessagesTimeline({
31843171 hasMessages,
31853172 isWorking,
......@@ -3200,6 +3187,36 @@ const MessagesTimeline = memo(function MessagesTimeline({
32003187 onImageExpand,
32013188 markdownCwd,
32023189}: 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
32033220 const rows = useMemo<TimelineRow[]>(() => {
32043221 const nextRows: TimelineRow[] = [];
32053222
......@@ -3303,12 +3320,16 @@ const MessagesTimeline = memo(function MessagesTimeline({
33033320 if (!row) return 96;
33043321 if (row.kind === "work") return 112;
33053322 if (row.kind === "working") return 40;
3306 return estimateTimelineMessageHeight(row.message);
3323 return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
33073324 },
33083325 measureElement: measureVirtualElement,
33093326 useAnimationFrameWithResizeObserver: true,
33103327 overscan: 8,
33113328 });
3329 useEffect(() => {
3330 if (timelineWidthPx === null) return;
3331 rowVirtualizer.measure();
3332 }, [rowVirtualizer, timelineWidthPx]);
33123333 useEffect(() => {
33133334 rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
33143335 const viewportHeight = instance.scrollRect?.height ?? 0;
......@@ -3624,7 +3645,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
36243645 }
36253646
36263647 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">
36283649 {virtualizedRowCount > 0 && (
36293650 <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
36303651 {virtualRows.map((virtualRow: VirtualItem) => {
apps/web/src/components/timelineHeight.test.ts created+88
......@@ -0,0 +1,88 @@
1import { describe, expect, it } from "vitest";
2
3import { estimateTimelineMessageHeight } from "./timelineHeight";
4
5describe("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 @@
1const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;
2const USER_CHARS_PER_LINE_FALLBACK = 56;
3const LINE_HEIGHT_PX = 22;
4const ASSISTANT_BASE_HEIGHT_PX = 78;
5const USER_BASE_HEIGHT_PX = 96;
6const ATTACHMENTS_PER_ROW = 2;
7// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap.
8const USER_ATTACHMENT_ROW_HEIGHT_PX = 228;
9const USER_BUBBLE_WIDTH_RATIO = 0.8;
10const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32;
11const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8;
12const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4;
13const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2;
14const MIN_USER_CHARS_PER_LINE = 16;
15const MIN_ASSISTANT_CHARS_PER_LINE = 20;
16
17interface TimelineMessageHeightInput {
18 role: "user" | "assistant" | "system";
19 text: string;
20 attachments?: ReadonlyArray<{ id: string }>;
21}
22
23interface TimelineHeightEstimateLayout {
24 timelineWidthPx: number | null;
25}
26
27function 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
46function isFinitePositiveNumber(value: number | null | undefined): value is number {
47 return typeof value === "number" && Number.isFinite(value) && value > 0;
48}
49
50function 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
57function 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
66export 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}