import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; const USER_LINE_HEIGHT_PX = 22; const ASSISTANT_LINE_HEIGHT_PX = 22.75; // Assistant rows render as markdown content plus a compact timestamp meta line. // The DOM baseline is much smaller than the user bubble chrome, so model it // separately instead of reusing the old shared constant. const ASSISTANT_BASE_HEIGHT_PX = 41; const USER_BASE_HEIGHT_PX = 96; const ATTACHMENTS_PER_ROW = 2; // Full-app browser measurements land closer to a ~116px attachment row once // the bubble shrinks to content width, so calibrate the estimate to that DOM. const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; 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 * ASSISTANT_LINE_HEIGHT_PX; } if (message.role === "user") { const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); const displayedUserMessage = deriveDisplayedUserMessageState(message.text); const renderedText = displayedUserMessage.contexts.length > 0 ? [ buildInlineTerminalContextText(displayedUserMessage.contexts), displayedUserMessage.visibleText, ] .filter((part) => part.length > 0) .join(" ") : displayedUserMessage.visibleText; const estimatedLines = estimateWrappedLineCount(renderedText, 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 * USER_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 * ASSISTANT_LINE_HEIGHT_PX; }