1057 lines · 35.7 KB · TypeScript
RawDownload
import * as Equal from "effect/Equal";import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";import {  formatDuration,  workEntryDisplayIndicatesToolFailure,  workEntryIndicatesToolNeutralStatus,  workLogEntryIsToolLike,  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 TIMELINE_MINIMAP_ITEM_SPACING = 8;export const TIMELINE_MINIMAP_MIN_ITEMS = 2;export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";export const TIMELINE_CONTENT_MAX_WIDTH = 768;export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; export function workEntryIsVisibleInGroup(  entry: WorkLogEntry,  expandedToolGroupEntry = false,): boolean {  return (    (expandedToolGroupEntry &&      (entry.toolLifecycleStatus === "inProgress" ||        entry.sourceActivityKind === "task.progress")) ||    !workEntryIndicatesToolNeutralStatus(entry)  );} export interface TimelineEndState {  readonly isAtEnd?: boolean;  readonly contentLength?: number;  readonly scroll?: number;  readonly scrollLength?: number;} /** * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's * isNearEnd fires within half a viewport, which re-armed live-follow while the * user was reading history and yanked them back down on the next stream chunk. * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming * reliable while streaming content is still growing under the viewport. */export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; export function resolveTimelineIsAtEnd(  state: TimelineEndState | undefined,  endInset = 0,): boolean | undefined {  if (!state) {    return undefined;  }  if (state.isAtEnd) {    return true;  }  const { contentLength, scroll, scrollLength } = state;  if (contentLength === undefined || scroll === undefined || scrollLength === undefined) {    return state.isAtEnd;  }  // contentLength includes the end inset (composer overlay), so subtract it to  // measure the distance to the real content bottom.  return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;} export function shouldPreserveAssistantLineBreaks(text: string): boolean {  return /^★ Insight(?:\s|─)/mu.test(text);} export function resolveTimelineMinimapHeightStyle(itemCount: number): string {  const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING);  return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`;} export function resolveTimelineMinimapTopPercent(index: number, itemCount: number): number {  if (itemCount <= 1) {    return 0;  }  return (Math.max(0, Math.min(index, itemCount - 1)) / (itemCount - 1)) * 100;} export function resolveTimelineMinimapIndexFromPointer(input: {  readonly itemCount: number;  readonly railTop: number;  readonly railHeight: number;  readonly pointerY: number;}): number | null {  if (input.itemCount <= 0 || input.railHeight <= 0) {    return null;  }  if (input.itemCount === 1) {    return 0;  }   const progress = Math.max(0, Math.min(1, (input.pointerY - input.railTop) / input.railHeight));  return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1))));} export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {  if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {    return false;  }   const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);  const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);  return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;} export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40;export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; /** * The minimap overlays the viewport's left edge while the content column is * centered, so the side gutter between them shrinks under browser zoom or a * narrow pane. A fixed-width hover strip would then sit on top of the message * text and swallow its pointer events. Cap the strip's width so it never * extends past the gutter into the content column; 0 disables the strip. */export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number {  if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {    return 0;  }   const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);  const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);  return Math.max(    0,    Math.min(      TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH,      Math.floor(sideGutter) - TIMELINE_MINIMAP_HIT_STRIP_LEFT,    ),  );} /** * Once the preview is open, keep the full preview and the space leading to it * interactive. The collapsed strip remains gutter-capped so it cannot block * selecting message text. */export function resolveTimelineMinimapInteractiveWidth(  collapsedWidth: number,  expanded: boolean,): number | string {  return expanded ? TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH : collapsedWidth;} 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[];      isExpandedToolGroupEntry: boolean;      isLastExpandedToolGroupEntry: boolean;    }  | {      kind: "work-live";      id: string;      createdAt: string;      entry: WorkLogEntry;      groupedEntries: WorkLogEntry[];      groupId: string;      expanded: boolean;    }  | {      kind: "work-toggle";      id: string;      createdAt: string;      groupId: string;      hiddenCount: number;      expanded: boolean;      summary: string;      summaryKind: ToolGroupSummaryKind;      hasFailure: boolean;    }  | {      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;      showThinking: boolean;    }; 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();} type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed"; export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {  return (    entry.itemType === "web_search" &&    /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))  );} export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {  if (    entry.requestKind === "file-read" ||    entry.itemType === "image_view" ||    (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")  ) {    return "read";  }  if (    entry.requestKind === "file-change" ||    entry.itemType === "file_change" ||    (entry.changedFiles?.length ?? 0) > 0  ) {    return "edit";  }  if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {    return "command";  }  if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";  if (entry.itemType === "web_search") return "search";  return workLogEntryIsToolLike(entry) ? "other" : "update";} function toolGroupActionCount(  action: ToolGroupAction,  entries: ReadonlyArray<WorkLogEntry>,): number {  if (action !== "edit") return entries.length;   const changedFiles = new Set<string>();  let editsWithoutFileDetails = 0;  for (const entry of entries) {    if (!entry.changedFiles || entry.changedFiles.length === 0) {      editsWithoutFileDetails += 1;      continue;    }    for (const file of entry.changedFiles) changedFiles.add(file);  }  return changedFiles.size + editsWithoutFileDetails;} function toolGroupActionLabel(action: ToolGroupAction, count: number): string {  switch (action) {    case "read":      return `Read ${count} ${count === 1 ? "file" : "files"}`;    case "edit":      return `Changed ${count} ${count === 1 ? "file" : "files"}`;    case "command":      return `Ran ${count} ${count === 1 ? "command" : "commands"}`;    case "search":      return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;    case "code-search":      return `Searched code ${count} ${count === 1 ? "time" : "times"}`;    case "other":      return `Used ${count} ${count === 1 ? "tool" : "tools"}`;    case "update":      return `Received ${count} ${count === 1 ? "update" : "updates"}`;  }} /** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {  const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry);  const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();  for (const entry of summaryEntries) {    const action = toolGroupAction(entry);    const group = groupedEntries.get(action);    if (group) group.push(entry);    else groupedEntries.set(action, [entry]);  }  const labels = [...groupedEntries].map(([action, actionEntries]) =>    toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),  );  const sentenceLabels = labels.map((label, index) =>    index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),  );  if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";  if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");  return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;} function omitSupersededLifecycleMarkers<T>(  entries: readonly T[],  workEntryFor: (entry: T) => WorkLogEntry,): T[] {  const laterTerminalIdentities = new Set<string>();  const reversedEntries: T[] = [];   for (let index = entries.length - 1; index >= 0; index -= 1) {    const entry = entries[index]!;    const workEntry = workEntryFor(entry);    const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label);    const identity = [      workEntry.turnId ?? "no-turn",      workEntry.itemType ?? "",      normalizedLabel,    ].join("\u001f");    const isStatuslessIdlessMarker =      workEntry.toolCallId === undefined &&      workEntry.toolLifecycleStatus === undefined &&      (workEntry.sourceActivityKind === "tool.started" ||        workEntry.sourceActivityKind === "tool.updated");    if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue;     reversedEntries.push(entry);    if (      workEntry.sourceActivityKind === "tool.completed" ||      (workEntry.toolLifecycleStatus !== undefined &&        workEntry.toolLifecycleStatus !== "inProgress")    ) {      laterTerminalIdentities.add(identity);    }  }   return reversedEntries.toReversed();} function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupSummaryKind {  const actions = new Set(entries.map(toolGroupAction));  if (actions.size !== 1) return "mixed";   const action = actions.values().next().value!;  if (action !== "other") return action;   const fallbackKinds = new Set(    entries.map((entry): ToolGroupSummaryKind => {      if (entry.itemType === "mcp_tool_call") return "other";      if (entry.itemType === "dynamic_tool_call") return "dynamic-tool";      if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool";      if (entry.tone === "thinking") return "agent-tool";      if (entry.tone === "tool") return "tone-tool";      return "other";    }),  );  return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";} function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {  return entry.toolCallId    ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`    : timelineEntryId;} function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string {  return `work-group:${workGroupIdentity(timelineEntryId, entry)}`;} export function resolveAssistantMessageCopyState({  text,  showCopyButton,  streaming,}: {  text: string | null;  showCopyButton: boolean;  streaming: boolean;}) {  const hasText = text !== null && text.trim().length > 0;  const visible = showCopyButton && hasText && !streaming;  return {    text: hasText ? (visible ? renderCodexDirectivesForCopy(text) : text) : null,    visible,  };} 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 session's running turn is authoritative when latestTurn briefly lags or * regresses behind it. Otherwise, the latest turn counts as unsettled while it * is still running (or has not recorded a completion). This is deliberately * keyed on turn 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,  runningTurnId: TurnId | null,): TurnId | null {  if (runningTurnId !== null) {    return runningTurnId;  }  if (!latestTurn) {    return null;  }  const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running";  return isSettled ? null : latestTurn.turnId;} function lastUserMessageIndex(timelineEntries: ReadonlyArray<TimelineEntry>): number {  return timelineEntries.findLastIndex(

Lines 1–500 of 1057.