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 = 4; 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 === "assistant") { const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; } if (message.role === "user") { 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; } // `system` messages are not rendered in the chat timeline, but keep a stable // explicit branch in case they are present in timeline data. const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; }