diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9c24d883b50829aa594411800f4dd959fe33d6f2..d7da2fb29bab52ec1df2f1a5cfeed14dc79bef24 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -131,6 +131,7 @@ import { import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { estimateTimelineMessageHeight } from "./timelineHeight"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -3166,20 +3167,6 @@ type TimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; -function estimateTimelineMessageHeight(message: TimelineMessage): number { - const textLength = message.text.length; - if (message.role === "assistant") { - const estimatedLines = Math.max(1, Math.ceil(textLength / 72)); - return 78 + Math.min(estimatedLines * 22, 820); - } - - const estimatedLines = Math.max(1, Math.ceil(textLength / 56)); - const attachmentCount = message.attachments?.length ?? 0; - const attachmentRows = Math.ceil(attachmentCount / 2); - const attachmentHeight = attachmentRows * 124; - return 96 + Math.min(estimatedLines * 22, 620) + attachmentHeight; -} - const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, @@ -3200,6 +3187,36 @@ const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, }: MessagesTimelineProps) { + const timelineRootRef = useRef(null); + const [timelineWidthPx, setTimelineWidthPx] = useState(null); + + useLayoutEffect(() => { + const timelineRoot = timelineRootRef.current; + if (!timelineRoot) return; + + const updateWidth = (nextWidth: number) => { + setTimelineWidthPx((previousWidth) => { + if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { + return previousWidth; + } + return nextWidth; + }); + }; + + updateWidth(timelineRoot.getBoundingClientRect().width); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const [entry] = entries; + if (!entry) return; + updateWidth(entry.contentRect.width); + }); + observer.observe(timelineRoot); + return () => { + observer.disconnect(); + }; + }, []); + const rows = useMemo(() => { const nextRows: TimelineRow[] = []; @@ -3303,12 +3320,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (!row) return 96; if (row.kind === "work") return 112; if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message); + return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, overscan: 8, }); + useEffect(() => { + if (timelineWidthPx === null) return; + rowVirtualizer.measure(); + }, [rowVirtualizer, timelineWidthPx]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; @@ -3624,7 +3645,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
+
{virtualizedRowCount > 0 && (
{virtualRows.map((virtualRow: VirtualItem) => { diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..df9d00aac5db9bd7c1f1032b82e2ed8c816c9f39 --- /dev/null +++ b/apps/web/src/components/timelineHeight.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { estimateTimelineMessageHeight } from "./timelineHeight"; + +describe("estimateTimelineMessageHeight", () => { + it("uses assistant sizing rules for assistant messages", () => { + expect( + estimateTimelineMessageHeight({ + role: "assistant", + text: "a".repeat(144), + }), + ).toBe(122); + }); + + it("adds one attachment row for one or two user attachments", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }], + }), + ).toBe(346); + + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }], + }), + ).toBe(346); + }); + + it("adds a second attachment row for three or four user attachments", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], + }), + ).toBe(574); + + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], + }), + ).toBe(574); + }); + + it("does not cap long user message estimates", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "a".repeat(56 * 120), + }), + ).toBe(2736); + }); + + it("counts explicit newlines for user message estimates", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "first\nsecond\nthird", + }), + ).toBe(162); + }); + + it("uses narrower width to increase user line wrapping", () => { + const message = { + role: "user" as const, + text: "a".repeat(52), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); + }); + + it("uses narrower width to increase assistant line wrapping", () => { + const message = { + role: "assistant" as const, + text: "a".repeat(200), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); + }); +}); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bf9e1103c8aa1272dd5ed8ffd9784a5abf11712 --- /dev/null +++ b/apps/web/src/components/timelineHeight.ts @@ -0,0 +1,82 @@ +const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; +const USER_CHARS_PER_LINE_FALLBACK = 56; +const LINE_HEIGHT_PX = 22; +const ASSISTANT_BASE_HEIGHT_PX = 78; +const USER_BASE_HEIGHT_PX = 96; +const ATTACHMENTS_PER_ROW = 2; +// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. +const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; +const USER_BUBBLE_WIDTH_RATIO = 0.8; +const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; +const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; +const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; +const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; +const MIN_USER_CHARS_PER_LINE = 16; +const MIN_ASSISTANT_CHARS_PER_LINE = 20; + +interface TimelineMessageHeightInput { + role: "user" | "assistant" | "system"; + text: string; + attachments?: ReadonlyArray<{ id: string }>; +} + +interface TimelineHeightEstimateLayout { + timelineWidthPx: number | null; +} + +function estimateWrappedLineCount(text: string, charsPerLine: number): number { + if (text.length === 0) return 1; + + // Avoid allocating via split for long logs; iterate once and count wrapped lines. + let lines = 0; + let currentLineLength = 0; + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) === 10) { + lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); + currentLineLength = 0; + continue; + } + currentLineLength += 1; + } + + lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); + return lines; +} + +function isFinitePositiveNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { + if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; + const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; + const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); + return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); +} + +function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { + if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; + const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); + return Math.max( + MIN_ASSISTANT_CHARS_PER_LINE, + Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), + ); +} + +export function estimateTimelineMessageHeight( + message: TimelineMessageHeightInput, + layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, +): number { + if (message.role !== "user") { + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; + } + + const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const attachmentCount = message.attachments?.length ?? 0; + const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); + const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; + return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; +}