// Pure presentation decisions for a timeline turn's work, shared by the web and native // timelines. Every function here is extracted verbatim from the web MessageTimeline so both // renderers decide identically what a turn shows (preparing, working, waiting, worked). import { timelineGroupContainsPresentedImage } from "./presented-image"; import type { TurnSummaryStatus } from "./turn-summary-model"; import type { ActivityItem, TimelineGroup, ToolCallItem } from "./types"; import { type OpenGeniText } from "../messages/runtime"; export type ActivityGroup = Extract; export type TurnGroup = Extract; export function durationBetween(startedAt: string, endedAt: string): number | undefined { const started = Date.parse(startedAt); const ended = Date.parse(endedAt); if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) { return undefined; } return ended - started; } /** Compaction landmarks among a fold's groups (secondary summary facet). */ export function compactedLandmarkCount(groups: readonly TimelineGroup[]): number { return groups.filter( (group) => group.kind === "item" && group.item.kind === "context-compaction" && group.item.phase === "compacted", ).length; } export function clusterIsSettled(group: ActivityGroup): boolean { return group.items.every((item) => { if (item.kind === "reasoning" || item.kind === "agent-message") { return !item.streaming; } // Memory writes and fleet observations are discrete, already-settled events. if (item.kind === "memory" || item.kind === "fleet-decision") { return true; } return item.status !== "running"; }); } export function flattenActivityItems(groups: readonly TimelineGroup[]): ActivityItem[] { const items: ActivityItem[] = []; for (const group of groups) { if (group.kind === "activity") { items.push(...flattenActivityItems(group.work?.details ?? []), ...group.items); } else if (group.kind === "turn") { items.push(...flattenActivityItems(group.groups)); } } return items; } export type ReadableWorkOptions = { /** Host shows startup phases as ordinary details (web: startup-details context). */ startupDetails: boolean; /** Startup chrome was dismissed by the reader. */ startupDismissed: boolean; }; /** * A readable turn whose only work so far is environment preparation renders as one quiet * preparation surface instead of a "Working" fold. */ export function isPreparingWork(group: ActivityGroup, options: ReadableWorkOptions): boolean { if (!group.work) return false; // Steps not loaded yet are work the reader can open, not preparation. if (group.work.steps && group.work.steps.state !== "complete") return false; const phases = group.items.filter((item) => item.kind === "startup-phase"); return ( !options.startupDetails && !options.startupDismissed && !group.work.endedAt && !group.work.waiting && phases.length > 0 && group.items.every( (item) => item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()), ) && !phases.some((item) => item.status === "failed" || item.status === "cancelled") ); } /** * Whether a readable turn's work row has anything to reveal when expanded. * Unless the host shows startup phases as details, the work row's rail only * renders failed or cancelled preparation steps, so a detail made only of * completed or still-running steps (for example the queue phase before a turn * was parked on a usage limit) renders nothing and does not make the row * expandable. */ export function readableWorkHasDetails( group: ActivityGroup & { work: NonNullable }, options: Pick, ): boolean { // Steps not loaded yet are revealed (and loaded) by opening the row. if (group.work.steps && group.work.steps.state !== "complete") return true; return group.work.details.some( (detail) => options.startupDetails || detail.kind !== "activity" || Boolean(detail.work) || !detail.items.every( (item) => (item.kind === "startup-phase" && item.status !== "failed" && item.status !== "cancelled") || (item.kind === "reasoning" && !item.text.trim()), ), ); } /** * Status of a readable turn's work row, minus the host-rendered live preview. * Time spent waiting for model capacity is not work: the worked duration and * the live working clock exclude it, and the wait itself shows no clock. A * wait's `detail` is its one quiet secondary line. */ export function readableWorkStatus( t: OpenGeniText, group: ActivityGroup & { work: NonNullable }, ): Omit & { detail?: string | undefined } { const end = group.work.endedAt; const pausedMs = group.work.pausedMs ?? 0; if (end) { const durationMs = durationBetween(group.work.startedAt, end); const cycles = group.work.cycles; // Folded quiet cycles report their wall-clock span: waits are the point. if (cycles) return { kind: "worked", label: t("timeline.workPresentation.countUpdatesOver", { count: cycles.count }), durationMs, }; return { kind: "worked", durationMs: durationMs === undefined ? undefined : Math.max(0, durationMs - pausedMs), }; } if (group.work.waiting) { const { untimed, since, ...waiting } = group.work.waiting; return { kind: "waiting", ...waiting, ...(untimed ? {} : { since }) }; } const started = Date.parse(group.work.startedAt); return { kind: "working", since: pausedMs > 0 && Number.isFinite(started) ? new Date(started + pausedMs).toISOString() : group.work.startedAt, }; } /** Whether a readable turn's work fold starts expanded. */ export function readableWorkDefaultOpen(group: ActivityGroup): true | undefined { return timelineGroupContainsPresentedImage(group) || group.outcome === "failed" || group.items.some( (item) => item.kind === "startup-phase" && (item.status === "failed" || // A cancelled phase with a blocked reason is an expected wait (for // example compacting the conversation first), not an interruption. (item.status === "cancelled" && !item.blockedReason)), ) ? true : undefined; } /** Whether the live preview should show (images stay primary output instead). */ export function readableWorkShowsPreview(group: ActivityGroup): boolean { return !timelineGroupContainsPresentedImage(group); } /** * The step a rolling preview shows: steps advance with event order (progress notes and * startup phases have their own surfaces). Mirrors the web RollingActivity selection. */ export function rollingActivityItem( items: readonly ActivityItem[], previousItem?: ActivityItem, mounted = true, ): { item: ActivityItem; running: boolean; earlierCount: number } | null { const work = items.filter( (item) => item.kind !== "startup-phase" && item.kind !== "agent-message", ); const active = work.filter((item) => item.kind === "reasoning" ? item.streaming : "status" in item && item.status === "running", ); const item = !mounted && previousItem && previousItem.kind !== "agent-message" ? previousItem : work.at(-1); if (!item) return null; return { item, running: active.some((entry) => entry.id === item.id), earlierCount: Math.max( 0, work.findIndex((entry) => entry.id === item.id), ), }; } export function isToolCall(item: ActivityItem): item is ToolCallItem { return item.kind === "tool-call"; }