import * as Equal from "effect/Equal";import { formatDuration, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, type TurnPlanEntry, 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;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 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 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[]; } | { kind: "work-toggle"; id: string; createdAt: string; groupId: string; hiddenCount: number; expanded: boolean; onlyToolEntries: 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: "turn-plan"; id: string; createdAt: string; turnPlan: TurnPlanEntry; } | { 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 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;} /** * 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) { continue; } // Agent-spawn CTA rows never fold: workflows outlive their launching // turn (dynamic spawns, background execution), and folding the CTA // when the turn settles makes a still-running fleet invisible. if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { continue; } 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; runningTurnId?: TurnId | null; expandedTurnIds?: ReadonlySet<TurnId>; expandedWorkGroupIds?: ReadonlySet<string>; 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, input.runningTurnId ?? 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; }Lines 1–500 of 713. Load more
import * as Equal from "effect/Equal"; import { formatDuration, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, type TurnPlanEntry, 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; 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 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 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[]; } | { kind: "work-toggle"; id: string; createdAt: string; groupId: string; hiddenCount: number; expanded: boolean; onlyToolEntries: 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: "turn-plan"; id: string; createdAt: string; turnPlan: TurnPlanEntry; } | { 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 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; } /** * 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) { continue; } // Agent-spawn CTA rows never fold: workflows outlive their launching // turn (dynamic spawns, background execution), and folding the CTA // when the turn settles makes a still-running fleet invisible. if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { continue; } 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; runningTurnId?: TurnId | null; expandedTurnIds?: ReadonlySet<TurnId>; expandedWorkGroupIds?: ReadonlySet<string>; 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, input.runningTurnId ?? 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; }