import { type MessageId } from "@t3tools/contracts"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; import { estimateTimelineMessageHeight } from "../timelineHeight"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; createdAt: string; completedAt?: string | undefined; } export type MessagesTimelineRow = | { kind: "work"; id: string; createdAt: string; groupedEntries: WorkLogEntry[]; } | { kind: "message"; id: string; createdAt: string; message: ChatMessage; durationStart: string; showCompletionDivider: boolean; } | { kind: "proposed-plan"; id: string; createdAt: string; proposedPlan: ProposedPlan; } | { kind: "working"; id: string; createdAt: string | null }; export function computeMessageDurationStart( messages: ReadonlyArray, ): Map { const result = new Map(); let lastBoundary: string | null = null; for (const message of messages) { if (message.role === "user") { lastBoundary = message.createdAt; } result.set(message.id, lastBoundary ?? message.createdAt); if (message.role === "assistant" && message.completedAt) { lastBoundary = message.completedAt; } } return result; } export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; completionDividerBeforeEntryId: string | null; isWorking: boolean; activeTurnStartedAt: string | null; }): MessagesTimelineRow[] { const nextRows: MessagesTimelineRow[] = []; const durationStartByMessageId = computeMessageDurationStart( input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; while (cursor < input.timelineEntries.length) { const nextEntry = input.timelineEntries[cursor]; if (!nextEntry || nextEntry.kind !== "work") break; groupedEntries.push(nextEntry.entry); cursor += 1; } nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries, }); index = cursor - 1; continue; } if (timelineEntry.kind === "proposed-plan") { nextRows.push({ kind: "proposed-plan", id: timelineEntry.id, createdAt: timelineEntry.createdAt, proposedPlan: timelineEntry.proposedPlan, }); continue; } nextRows.push({ kind: "message", id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, durationStart: durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, showCompletionDivider: timelineEntry.message.role === "assistant" && input.completionDividerBeforeEntryId === timelineEntry.id, }); } if (input.isWorking) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, }); } return nextRows; } export function estimateMessagesTimelineRowHeight( row: MessagesTimelineRow, input: { timelineWidthPx: number | null; expandedWorkGroups?: Readonly>; turnDiffSummaryByAssistantMessageId?: ReadonlyMap; }, ): number { switch (row.kind) { case "work": return estimateWorkRowHeight(row, input); case "proposed-plan": return estimateTimelineProposedPlanHeight(row.proposedPlan); case "working": return 40; case "message": { let estimate = estimateTimelineMessageHeight(row.message, { timelineWidthPx: input.timelineWidthPx, }); const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id); if (turnDiffSummary && turnDiffSummary.files.length > 0) { estimate += estimateChangedFilesCardHeight(turnDiffSummary); } return estimate; } } } function estimateWorkRowHeight( row: Extract, input: { expandedWorkGroups?: Readonly>; }, ): number { const isExpanded = input.expandedWorkGroups?.[row.id] ?? false; const hasOverflow = row.groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded ? MAX_VISIBLE_WORK_LOG_ENTRIES : row.groupedEntries.length; const onlyToolEntries = row.groupedEntries.every((entry) => entry.tone === "tool"); const showHeader = hasOverflow || !onlyToolEntries; // Card chrome, optional header, and one compact work-entry row per visible entry. return 28 + (showHeader ? 26 : 0) + visibleEntries * 32; } function estimateTimelineProposedPlanHeight(proposedPlan: ProposedPlan): number { const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); return 120 + Math.min(estimatedLines * 22, 880); } function estimateChangedFilesCardHeight(turnDiffSummary: TurnDiffSummary): number { const treeNodes = buildTurnDiffTree(turnDiffSummary.files); const visibleNodeCount = countTurnDiffTreeNodes(treeNodes); // Card chrome: top/bottom padding, header row, and tree spacing. return 60 + visibleNodeCount * 25; } function countTurnDiffTreeNodes(nodes: ReadonlyArray): number { let count = 0; for (const node of nodes) { count += 1; if (node.kind === "directory") { count += countTurnDiffTreeNodes(node.children); } } return count; }