478 lines · 14.9 KB · TypeScript
RawDownload
import * as Equal from "effect/Equal";import { formatDuration, type TimelineEntry, type WorkLogEntry } from "../../session-logic";import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; function computeElapsedMs(startIso: string, endIso: string): number | null {  const start = Date.parse(startIso);  const end = Date.parse(endIso);  if (!Number.isFinite(start) || !Number.isFinite(end)) return null;  return Math.max(0, end - start);} function maxIsoTimestamp(a: string | null, b: string | null): string | null {  if (a === null) return b;  if (b === null) return a;  const aMs = Date.parse(a);  const bMs = Date.parse(b);  if (!Number.isFinite(aMs)) return b;  if (!Number.isFinite(bMs)) return a;  return bMs > aMs ? b : a;} export interface TimelineDurationMessage {  id: string;  role: "user" | "assistant" | "system";  createdAt: string;  updatedAt: string;  streaming: boolean;} export type TimelineLatestTurn = Pick<  OrchestrationLatestTurn,  "turnId" | "state" | "startedAt" | "completedAt">; export type MessagesTimelineRow =  | {      kind: "work";      id: string;      createdAt: string;      groupedEntries: WorkLogEntry[];    }  | {      kind: "turn-fold";      id: string;      createdAt: string;      turnId: TurnId;      label: string;      expanded: boolean;    }  | {      kind: "message";      id: string;      createdAt: string;      message: ChatMessage;      durationStart: string;      showAssistantMeta: boolean;      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.streaming) {      lastBoundary = message.updatedAt;    }  }   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());} interface TurnFold {  turnId: TurnId;  anchorEntryId: string;  createdAt: string;  hiddenEntryIds: ReadonlySet<string>;  label: string;} /** * The latest turn counts as unsettled while it is still running (or has not * recorded a completion). This is deliberately keyed on the turn's own * lifecycle rather than transient working state: right after the user sends * a message, the previous turn is still the "active" one until the server * creates the new turn, and folding must not flicker through that window. */function deriveUnsettledTurnId(latestTurn: TimelineLatestTurn | null): TurnId | null {  if (!latestTurn) {    return null;  }  const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running";  return isSettled ? null : latestTurn.turnId;} /** * Settled turns fold their commentary and tool activity behind a * "Worked for ..." row anchored at the turn's first foldable entry; the * terminal assistant message stays visible below the fold. */function deriveTurnFolds(input: {  timelineEntries: ReadonlyArray<TimelineEntry>;  terminalAssistantMessageIds: ReadonlySet<string>;  latestTurn: TimelineLatestTurn | null;  unsettledTurnId: TurnId | null;}): ReadonlyMap<string, TurnFold> {  interface TurnGroup {    entries: Array<TimelineEntry>;    terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;    hasStreamingMessage: boolean;    /**     * The user message that kicked the turn off. Entry timestamps alone     * undercount the duration (the first entry appears only once the     * provider starts producing output), and a turn cut short by a steer may     * hold a single instantaneous commentary message.     */    startBoundary: string | null;  }  const groupsByTurnId = new Map<TurnId, TurnGroup>();   let pendingUserBoundary: string | null = null;  for (const entry of input.timelineEntries) {    if (entry.kind === "message" && entry.message.role === "user") {      pendingUserBoundary = entry.message.createdAt;      continue;    }    const turnId =      entry.kind === "message" && entry.message.role === "assistant"        ? (entry.message.turnId ?? null)        : entry.kind === "work"          ? (entry.entry.turnId ?? null)          : null;    if (!turnId) {      continue;    }    let group = groupsByTurnId.get(turnId);    if (!group) {      group = {        entries: [],        terminalEntry: null,        hasStreamingMessage: false,        // Each user boundary starts at most one turn; a second turn after the        // same user message (e.g. a steer-superseded continuation) falls back        // to its own first entry.        startBoundary: pendingUserBoundary,      };      pendingUserBoundary = null;      groupsByTurnId.set(turnId, group);    }    group.entries.push(entry);    if (entry.kind === "message") {      if (input.terminalAssistantMessageIds.has(entry.message.id)) {        group.terminalEntry = entry;      }      if (entry.message.streaming) {        group.hasStreamingMessage = true;      }    }  }   const foldsByAnchorEntryId = new Map<string, TurnFold>();  for (const [turnId, group] of groupsByTurnId) {    if (turnId === input.unsettledTurnId) {      continue;    }    if (group.hasStreamingMessage) {      continue;    }    const hiddenEntryIds = new Set<string>();    for (const entry of group.entries) {      if (entry.id !== group.terminalEntry?.id) {        hiddenEntryIds.add(entry.id);      }    }    if (hiddenEntryIds.size === 0) {      continue;    }     const firstEntry = group.entries[0];    const lastEntry = group.entries.at(-1);    if (!firstEntry || !lastEntry) {      continue;    }     const isLatestInterruptedTurn =      input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";    // A turn cut short by a steer leaves trailing work entries behind its    // terminal message — take whichever ended last.    const lastEntryEnd =      lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;    const elapsedMs =      input.latestTurn?.turnId === turnId &&      input.latestTurn.startedAt &&      input.latestTurn.completedAt        ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt)        : computeElapsedMs(            group.startBoundary ?? firstEntry.createdAt,            maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ??              lastEntryEnd,          );    const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null;    const label = isLatestInterruptedTurn      ? duration        ? `You stopped after ${duration}`        : "You stopped this response"      : duration        ? `Worked for ${duration}`        : "Worked";     foldsByAnchorEntryId.set(firstEntry.id, {      turnId,      anchorEntryId: firstEntry.id,      createdAt: firstEntry.createdAt,      hiddenEntryIds,      label,    });  }  return foldsByAnchorEntryId;} export function deriveMessagesTimelineRows(input: {  timelineEntries: ReadonlyArray<TimelineEntry>;  latestTurn?: TimelineLatestTurn | null;  expandedTurnIds?: ReadonlySet<TurnId>;  isWorking: boolean;  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);  const unsettledTurnId = deriveUnsettledTurnId(input.latestTurn ?? null);  const foldsByAnchorEntryId = deriveTurnFolds({    timelineEntries: input.timelineEntries,    terminalAssistantMessageIds,    latestTurn: input.latestTurn ?? null,    unsettledTurnId,  });  const collapsedEntryIds = new Set<string>();  for (const fold of foldsByAnchorEntryId.values()) {    if (!input.expandedTurnIds?.has(fold.turnId)) {      for (const entryId of fold.hiddenEntryIds) {        collapsedEntryIds.add(entryId);      }    }  }   for (let index = 0; index < input.timelineEntries.length; index += 1) {    const timelineEntry = input.timelineEntries[index];    if (!timelineEntry) {      continue;    }     const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);    if (turnFold) {      nextRows.push({        kind: "turn-fold",        id: `turn-fold:${turnFold.turnId}`,        createdAt: turnFold.createdAt,        turnId: turnFold.turnId,        label: turnFold.label,        expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,      });    }     if (collapsedEntryIds.has(timelineEntry.id)) {      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" ||          collapsedEntryIds.has(nextEntry.id) ||          foldsByAnchorEntryId.has(nextEntry.id)        ) {          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" &&      unsettledTurnId !== null &&      timelineEntry.message.turnId === unsettledTurnId;     const durationStart =      durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt;     // While the turn is still running, the latest assistant message is only    // provisionally terminal — withhold the metadata row until the turn    // settles so commentary doesn't flash timestamps mid-work.    const showAssistantMeta =      timelineEntry.message.role === "assistant" &&      terminalAssistantMessageIds.has(timelineEntry.message.id) &&      !assistantTurnStillInProgress;     nextRows.push({      kind: "message",      id: timelineEntry.id,      createdAt: timelineEntry.createdAt,      message: timelineEntry.message,      durationStart,      showAssistantMeta,      showAssistantCopyButton: showAssistantMeta,      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 "turn-fold": {      const bf = b as typeof a;      return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;    }     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.showAssistantMeta === bm.showAssistantMeta &&        a.showAssistantCopyButton === bm.showAssistantCopyButton &&        a.assistantCopyStreaming === bm.assistantCopyStreaming &&        a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&        a.revertTurnCount === bm.revertTurnCount      );    }  }}