258 lines · 8.0 KB · TypeScript
RawDownload
import * as Equal from "effect/Equal";import { type TimelineEntry, type WorkLogEntry } from "../../session-logic";import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";import { type MessageId, type TurnId } from "@t3tools/contracts"; 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;      completionSummary: string | null;      showAssistantCopyButton: boolean;      assistantCopyStreaming: boolean;      assistantTurnDiffSummary?: TurnDiffSummary | undefined;      revertTurnCount?: number | undefined;    }  | {      kind: "proposed-plan";      id: string;      createdAt: string;      proposedPlan: ProposedPlan;    }  | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState {  byId: Map<string, MessagesTimelineRow>;  result: MessagesTimelineRow[];} 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 resolveAssistantMessageCopyState({  text,  showCopyButton,  streaming,}: {  text: string | null;  showCopyButton: boolean;  streaming: boolean;}) {  const hasText = text !== null && text.trim().length > 0;  return {    text: hasText ? text : null,    visible: showCopyButton && hasText && !streaming,  };} function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<TimelineEntry>) {  const lastAssistantMessageIdByResponseKey = new Map<string, string>();  let nullTurnResponseIndex = 0;   for (const timelineEntry of timelineEntries) {    if (timelineEntry.kind !== "message") {      continue;    }    const { message } = timelineEntry;    if (message.role === "user") {      nullTurnResponseIndex += 1;      continue;    }    if (message.role !== "assistant") {      continue;    }     const responseKey = message.turnId      ? `turn:${message.turnId}`      : `unkeyed:${nullTurnResponseIndex}`;    lastAssistantMessageIdByResponseKey.set(responseKey, message.id);  }   return new Set(lastAssistantMessageIdByResponseKey.values());} export function deriveMessagesTimelineRows(input: {  timelineEntries: ReadonlyArray<TimelineEntry>;  completionDividerBeforeEntryId: string | null;  completionSummary?: string | null;  isWorking: boolean;  activeTurnInProgress?: boolean;  activeTurnId?: TurnId | null;  activeTurnStartedAt: string | null;  turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;  revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;}): MessagesTimelineRow[] {  const nextRows: MessagesTimelineRow[] = [];  const durationStartByMessageId = computeMessageDurationStart(    input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),  );  const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);   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;    }     const assistantTurnStillInProgress =      timelineEntry.message.role === "assistant" &&      input.activeTurnInProgress === true &&      input.activeTurnId != null &&      timelineEntry.message.turnId === input.activeTurnId;     const showCompletionDivider =      timelineEntry.message.role === "assistant" &&      input.completionDividerBeforeEntryId === timelineEntry.id;     nextRows.push({      kind: "message",      id: timelineEntry.id,      createdAt: timelineEntry.createdAt,      message: timelineEntry.message,      durationStart:        durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt,      showCompletionDivider,      completionSummary: showCompletionDivider ? (input.completionSummary ?? null) : null,      showAssistantCopyButton:        timelineEntry.message.role === "assistant" &&        terminalAssistantMessageIds.has(timelineEntry.message.id),      assistantCopyStreaming: timelineEntry.message.streaming || assistantTurnStillInProgress,      assistantTurnDiffSummary:        timelineEntry.message.role === "assistant"          ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)          : undefined,      revertTurnCount:        timelineEntry.message.role === "user"          ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id)          : undefined,    });  }   if (input.isWorking) {    nextRows.push({      kind: "working",      id: "working-indicator-row",      createdAt: input.activeTurnStartedAt,    });  }   return nextRows;} export function computeStableMessagesTimelineRows(  rows: MessagesTimelineRow[],  previous: StableMessagesTimelineRowsState,): StableMessagesTimelineRowsState {  const next = new Map<string, MessagesTimelineRow>();  let anyChanged = rows.length !== previous.byId.size;   const result = rows.map((row, index) => {    const prevRow = previous.byId.get(row.id);    const nextRow = prevRow && isRowUnchanged(prevRow, row) ? prevRow : row;    next.set(row.id, nextRow);    if (!anyChanged && previous.result[index] !== nextRow) {      anyChanged = true;    }    return nextRow;  });   return anyChanged ? { byId: next, result } : previous;} /** Shallow field comparison per row variant — avoids deep equality cost. */function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean {  if (a.kind !== b.kind || a.id !== b.id) return false;   switch (a.kind) {    case "working":      return a.createdAt === (b as typeof a).createdAt;     case "proposed-plan":      return a.proposedPlan === (b as typeof a).proposedPlan;     case "work":      return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);     case "message": {      const bm = b as typeof a;      return (        a.message === bm.message &&        a.durationStart === bm.durationStart &&        a.showCompletionDivider === bm.showCompletionDivider &&        a.completionSummary === bm.completionSummary &&        a.showAssistantCopyButton === bm.showAssistantCopyButton &&        a.assistantCopyStreaming === bm.assistantCopyStreaming &&        a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&        a.revertTurnCount === bm.revertTurnCount      );    }  }}