199 lines · 6.1 KB · TypeScript
RawDownload
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<TimelineDurationMessage>,): Map<string, string> {  const result = new Map<string, string>();  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<TimelineEntry>;  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<Record<string, boolean>>;    turnDiffSummaryByAssistantMessageId?: ReadonlyMap<MessageId, TurnDiffSummary>;  },): 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<MessagesTimelineRow, { kind: "work" }>,  input: {    expandedWorkGroups?: Readonly<Record<string, boolean>>;  },): 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<TurnDiffTreeNode>): number {  let count = 0;  for (const node of nodes) {    count += 1;    if (node.kind === "directory") {      count += countTurnDiffTreeNodes(node.children);    }  }  return count;}