import { ChevronRightIcon, CircleSlashIcon, ShrinkIcon, TriangleAlertIcon } from "lucide-react"; import { Component, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { Collapsible } from "radix-ui"; import { CopyButton } from "../components/copy-button"; import { cn } from "../lib/cn"; import { MOTION_INSPECT_SCALE } from "../lib/motion-inspect"; import { presentTurnFailureText } from "./compaction-copy"; import { useForcedDefaultOpen } from "./disclosure-context"; import { useTimelineSearchReveal } from "../components/timeline-search"; import { useEntranceAnimation } from "./entrance"; import { useFoldMemory, type FoldRestingState } from "./fold-memory"; import { BUILT_IN_TURN_SUMMARY_FACET_IDS, createTurnSummaryContext, formatElapsed, resolveTurnSummaryFacets, selectTurnSummaryFacets, turnSummaryFallbackLabel, type TurnSummaryFacetConfiguration, type TurnSummaryStatus, } from "./turn-summary-model"; import type { ActivityItem, TurnOutcome } from "./types"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; import { ShimmerText } from "./shimmer-text"; export { BUILT_IN_TURN_SUMMARY_FACET_IDS, formatElapsed }; export type { BuiltInTurnSummaryFacetId, TurnSummaryContext, TurnSummaryFacet, TurnSummaryFacetConfiguration, TurnSummaryFacetResult, TurnSummaryOptions, TurnSummaryStatus, } from "./turn-summary-model"; export type { TurnOutcome } from "./types"; /* ---------------------------------------------------------------------------- Turn summary A completed (or failed/cancelled) turn folds behind one quiet summary chip: "N steps · M files · K commands · 1 screenshot · 4m". The chip is the default surface; expanding it reveals the full settled turn body. Top-level live activity keeps the same open shell so settling never remounts the rail into a brand-new wrapper (that remount was the hard yank). This keeps the timeline calm: a finished turn is a single line until the reader chooses to look inside it. -------------------------------------------------------------------------- */ const TurnSettleChromeContext = createContext(false); /** * True while settle chrome is active (open beat, slow collapse, or the short * cancel-close latch). Nested cluster chips stay mounted and forced OPEN for * this window so the body keeps a stable height — never flat-map to bare * rails, and never remount closed nested chips mid-collapse (that yanked). */ export function useTurnSettleOpen(): boolean { return useContext(TurnSettleChromeContext); } export type TurnSummaryProps = { /** Reports the disclosure's actual open state on mount and on every change. */ onOpenStateChange?: ((open: boolean) => void) | undefined; /** The activity items in the turn (used only to compute the facet counts). */ items: ActivityItem[]; /** * The settled verdict — or absent for a completed CLUSTER of a still-running * turn, which folds neutrally. Only unfinished activity inside this summary * animates; the parent turn may still be composing a response elsewhere. */ outcome?: TurnOutcome | undefined; /** A short failure reason shown inline on a failed chip (never hidden). */ failureText?: string | undefined; /** Elapsed turn duration; shown as a trailing facet when at least 1s. */ durationMs?: number | undefined; /** Start expanded. */ defaultOpen?: boolean | undefined; liveHeader?: ReactNode; /** * A nested fold — a cluster or sub-turn INSIDE an already-expanded turn. It * drops the bordered/filled chip and renders as a plain disclosure node on the * parent's rail (chevron + glyph + facets), so expanding a turn reveals a thread * of nodes, never a stack of boxes-in-boxes. The top-level fold stays a chip. */ bare?: boolean | undefined; /** Per-instance facet customization. Omit to preserve the built-in summary exactly. */ facets?: TurnSummaryFacetConfiguration | undefined; /** * Settle choreography: this fold replaced rows the reader was just watching * live. Instead of yanking them behind a chip in one frame, the fold mounts * OPEN with the summary chip easing in above the still-visible rows, holds a * short beat so the reader registers the settle, then glides closed. Any * user interaction during the beat cancels the auto-collapse. Captured at * mount; ignored when the fold starts expanded (e.g. a failed turn). */ settleFold?: boolean | undefined; /** * Durable identity (timeline group id) for cross-remount fold memory. When * an ancestor provides a {@link FoldMemoryProvider} map, reaching a resting * state is recorded under this key: "closed" when the settle choreography * completes its collapse or the reader closes the chip, "open" when the * reader expands it. A later remount under the same key restores that * resting state and never replays the open settle beat — the activity→turn * wrap and the nested force-open during settle chrome must not re-expand a * fold that already settled closed. */ foldKey?: string | undefined; /** * When set, a hover/focus copy control sits on the chip row (outside the * disclosure trigger) so the reader can copy the turn's assistant prose * without toggling the fold. */ copyText?: string | undefined; /** Adjacent compaction landmark count for the secondary chip facet. */ contextCompactionCount?: number | undefined; /** * Exchange status line (compact progress presentation). Replaces the state * marker and the duration facet; a settled `worked` row reads as a separator. */ status?: TurnSummaryStatus | undefined; /** * False when the fold has nothing to reveal: the row renders without a * chevron and is not a disclosure control. Defaults to true. */ expandable?: boolean | undefined; /** The turn has steps that are not loaded yet (opening the row reads them). */ unloadedSteps?: boolean | undefined; /** The rendered activity rail revealed on expand. */ children: ReactNode; }; /** How long a settling fold stays open before gliding closed. */ const SETTLE_FOLD_BEAT_MS = 1100 * MOTION_INSPECT_SCALE; /** Keep in sync with `--og-duration-disclose-settle`. */ const SETTLE_COLLAPSE_MS = 820 * MOTION_INSPECT_SCALE; /** Keep in sync with `--og-duration-disclose` (manual / cancel-close). */ const DISCLOSE_MS = 120 * MOTION_INSPECT_SCALE; export function TurnSummary({ items, outcome, failureText, durationMs, defaultOpen, liveHeader, bare, facets: facetConfiguration, settleFold, foldKey, copyText, contextCompactionCount, status, expandable = true, unloadedSteps, onOpenStateChange, children, }: TurnSummaryProps) { const t = useOpenGeniText(); // An explicit `defaultOpen` always wins; otherwise an ancestor may seed it // (screenshot instrumentation); otherwise the turn starts folded. const forcedDefaultOpen = useForcedDefaultOpen(); const searchReveal = useTimelineSearchReveal(); const foldMemory = useFoldMemory(); // A remembered resting state outranks author defaults: a fold that already // finished its settle collapse (or that the reader closed) mounts closed // even when a remount asks for the settle beat or a forced defaultOpen — // and one the reader expanded mounts open instead of snapping shut. const remembered = foldKey !== undefined ? foldMemory?.get(foldKey) : undefined; const restingOpen = remembered === "closed" ? false : remembered === "open" ? true : (defaultOpen ?? forcedDefaultOpen ?? false); const initialSettle = Boolean(settleFold) && !restingOpen && remembered === undefined; const [settling, setSettling] = useState(initialSettle); const [openState, setOpen] = useState(initialSettle ? true : restingOpen); // A fold with nothing to reveal never reads as open. const open = expandable && openState; const reportOpen = useRef(onOpenStateChange); reportOpen.current = onOpenStateChange; // Before paint: a listener may need to start motion in the same frame. useLayoutEffect(() => { reportOpen.current?.(open); }, [open]); const readerOwnsOpen = useRef(remembered !== undefined); useEffect(() => { // Readable work can produce primary media or fail after mounting. Reveal // that output without remounting existing tool controls, while preserving // every explicit reader-owned open/closed choice. if (status && defaultOpen && remembered === undefined && !readerOwnsOpen.current) setOpen(true); }, [status, defaultOpen, remembered]); // While true, a close uses the slow settle collapse. Cleared after that // auto-collapse finishes (or on first user interaction) so later manual // closes are the fast disclose pair. const [settlePhase, setSettlePhase] = useState(initialSettle); // Separate from settlePhase CSS: keep nested chips force-open through // cancel-close (fast collapse) without forcing the slow settle-collapse. const [nestSuppressLatch, setNestSuppressLatch] = useState(initialSettle); // Expand animation must NOT run on the settle mount (rows were already // visible — a height sweep would flash them). Armed once we leave that // initial open, so a later manual reopen animates instead of snapping. const [expandReady, setExpandReady] = useState(!initialSettle); const settleTimerRef = useRef | null>(null); const settleCloseDoneRef = useRef | null>(null); const nestLatchClearRef = useRef | null>(null); const settleFoldSeenRef = useRef(Boolean(settleFold)); const settleArmedRef = useRef(false); const clearNestLatchTimer = () => { if (nestLatchClearRef.current !== null) { clearTimeout(nestLatchClearRef.current); nestLatchClearRef.current = null; } }; const clearSettleTimers = () => { if (settleTimerRef.current !== null) { clearTimeout(settleTimerRef.current); settleTimerRef.current = null; } if (settleCloseDoneRef.current !== null) { clearTimeout(settleCloseDoneRef.current); settleCloseDoneRef.current = null; } clearNestLatchTimer(); }; const rememberResting = (state: FoldRestingState) => { if (foldKey !== undefined && foldMemory) { foldMemory.set(foldKey, state); } }; const armSettleCollapse = () => { if (settleArmedRef.current) { return; } settleArmedRef.current = true; setSettling(true); setSettlePhase(true); setNestSuppressLatch(true); setExpandReady(false); setOpen(true); clearSettleTimers(); settleTimerRef.current = setTimeout(() => { settleTimerRef.current = null; setExpandReady(true); setOpen(false); // The glide toward closed IS the choreography completing — record it // now, so a wrap that lands mid-collapse still remounts this fold shut. rememberResting("closed"); settleCloseDoneRef.current = setTimeout(() => { settleCloseDoneRef.current = null; settleArmedRef.current = false; setSettlePhase(false); setSettling(false); setNestSuppressLatch(false); }, SETTLE_COLLAPSE_MS); }, SETTLE_FOLD_BEAT_MS); }; const mountSettleRef = useRef(initialSettle); // Mount-time settle (new turn wrap). // Mount-once settle arm + unmount timer cleanup — re-running on foldMemory // identity churn would restart the beat mid-choreography. useEffect(() => { if (mountSettleRef.current) { armSettleCollapse(); } return () => { clearSettleTimers(); settleArmedRef.current = false; }; // oxlint-disable-next-line react-hooks/exhaustive-deps }, []); // Live shell already open: settleFold rises later without remounting. // Do not require !restingOpen — live shells mount with defaultOpen and // only later receive settleFold. Failed turns mount with both at once // (seen=true), so they never take this edge. // Edge-trigger on settleFold only; foldMemory/foldKey are reopen guards. useEffect(() => { const was = settleFoldSeenRef.current; settleFoldSeenRef.current = Boolean(settleFold); if (!was && settleFold) { // A remembered resting state means this fold's story already resolved // once (choreography closed it, or the reader chose a state). Replaying // the open beat would re-expand it — the exact reopen this guards. if (foldKey !== undefined && foldMemory?.get(foldKey) !== undefined) { return; } armSettleCollapse(); } // oxlint-disable-next-line react-hooks/exhaustive-deps }, [settleFold]); useEffect(() => { if (!searchReveal) return; clearSettleTimers(); settleArmedRef.current = false; setSettling(false); setSettlePhase(false); setNestSuppressLatch(false); setOpen(true); rememberResting("open"); // Search is an explicit, persistent reader action, not a new fold default. // oxlint-disable-next-line react-hooks/exhaustive-deps }, [searchReveal]); const onOpenChange = (next: boolean) => { readerOwnsOpen.current = true; // The reader took over — cancel the pending auto-collapse for good. // Clear settle CSS phase immediately (fast collapse) but keep nest latch // through the disclose window so nested chips stay force-open mid-close // (remounting them closed here yanks height). const wasNestFlat = settling || settlePhase || nestSuppressLatch; clearSettleTimers(); settleArmedRef.current = false; rememberResting(next ? "open" : "closed"); setExpandReady(true); setSettlePhase(false); setSettling(false); if (next) { setNestSuppressLatch(false); setOpen(true); return; } setOpen(false); if (wasNestFlat) { setNestSuppressLatch(true); nestLatchClearRef.current = setTimeout(() => { nestLatchClearRef.current = null; setNestSuppressLatch(false); }, DISCLOSE_MS); } else { setNestSuppressLatch(false); } }; const enter = useEntranceAnimation(); // Capture once: after a settle choreography the chip is already on screen. // Re-applying `animate-og-enter` when `settling` clears was the post-collapse // flash (opacity replay on the summary row). const [allowEnterAnimation] = useState(() => Boolean(enter && !bare && !initialSettle && !restingOpen), ); // Hold duration (and its "· 8s" insertion) until settle choreography finishes. // Surfacing it mid-beat or on the activity→turn remount made the chip text // reflow twice and read as another flash after the fold. const context = useMemo( () => createTurnSummaryContext( t, items, outcome, failureText, settling || settlePhase ? undefined : durationMs, contextCompactionCount ?? 0, ), [items, outcome, failureText, durationMs, settling, settlePhase, contextCompactionCount, t], ); const facetDefinitions = useMemo( () => resolveTurnSummaryFacets(facetConfiguration), [facetConfiguration], ); const statusKind = status?.kind; const facets = useMemo( () => selectTurnSummaryFacets(facetDefinitions, context, statusKind), [context, facetDefinitions, statusKind], ); // The chip names the failure; recorded technical text stays a hover title. const failure = outcome === "failed" ? presentTurnFailureText(t, failureText) : null; // Live open shell: keep the chip in-flow (so settle never inserts layout) // and quieter until there is an outcome or a settle beat — but still // clickable. `pointer-events-none` here trapped readers who expanded (or // who landed on the default-open live rail) with no way to collapse while // the turn was still running. const liveShell = outcome === undefined && open && !settlePhase && !bare; // Settle CSS phase OR cancel-close latch — see useTurnSettleOpen. // Nested chips stay force-open for this window (stable height). const settleChrome = settling || settlePhase || nestSuppressLatch; const statusLine = status ? turnSummaryStatusLine(t, status) : null; // Copy only on the collapsed chip — when open, per-message copy is enough // and a second control on the summary row felt crowded / off. const copyable = Boolean( copyText && copyText.trim().length > 0 && !bare && !liveShell && !open && !settlePhase, ); const headerClassName = cn( // The section, not the viewport, bounds this sticky row. Content // is a sibling: its disclosure overflow never traps the header. // Nested rail folds must never stack additional sticky headers. !bare && open && "sticky top-[var(--og-work-header-top,0px)] z-10 bg-og-bg", settling && expandable && "animate-og-settle-chip", // Top-level turn fold and (when used) nested cluster folds render as // FLAT rail rows — chevron + glyph + facets on the page background, no // border, no fill. Only a hover tint hints the row is expandable, so a // collapsed turn never reads as a boxed card. The top-level row is a // touch larger (base text, size-5 glyph, wider gap) so it still reads // as a turn landmark above any nested cluster rows it groups. "group flex w-full items-center rounded-og-sm text-left transition-colors", // A folded turn is a touch target on coarse pointers: grow the row so it // clears the 44px minimum without disturbing the calm desktop rhythm. expandable && "pointer-coarse:min-h-11 pointer-coarse:py-2.5", bare ? "gap-2 px-1.5 py-1.5 text-og-sm text-og-fg-muted" : "-mx-2 gap-2.5 px-2 py-1.5 text-og-base text-og-fg-muted", // A failed fold keeps its red accent (glyph + inline reason below) and a // faint red hover wash so attention still lands there; every other // outcome gets the neutral surface hover. A row with nothing to reveal // is not a control and gets no hover. !expandable ? undefined : outcome === "failed" ? "hover:bg-og-status-failed/[0.06] hover:text-og-fg" : "hover:bg-og-surface-1 hover:text-og-fg", liveShell && "text-og-fg-subtle", // Phones hide the hint, so keep the copy control clear of the separator. status && copyable && "max-sm:pr-10", ); const headerContent = ( <> {/* Disclosure grammar matches the rows: chevron leads (far left), then any exceptional or active state, then the facets — one expand affordance side everywhere. A row with nothing to reveal keeps the chevron's space so its text stays aligned with neighbouring rows. */} {expandable ? ( ) : ( )} {/* Completion is the quiet default and needs no repeated glyph. Failed, cancelled, and still-running folds retain a visible state marker. */} {status || outcome === "complete" || (!outcome && (liveHeader || context.settled)) ? null : ( {outcome === "failed" ? ( ) : outcome === "cancelled" ? ( ) : ( )} )} {statusLine} {liveHeader && !open && !status ? liveHeader : !statusLine && facets.length === 0 && !failure && outcome !== "cancelled" ? turnSummaryFallbackLabel(t, context.items, unloadedSteps) : facets.map(({ facet, result }, index) => ( <> {index > 0 || statusLine ? " · " : null} {result.icon ? ( {result.icon} ) : null} {result.content} ))} {failure ? ( {" "} · {failure.summary} ) : null} {outcome === "cancelled" ? ( {t("timeline.turnSummary.interrupted")} ) : null} {/* A settled exchange reads as a separator above its answer. */} {status?.kind === "worked" ? ( ) : null} {status && status.kind !== "worked" && (contextCompactionCount ?? 0) > 0 ? ( ) : null} ); return (
{expandable ? ( {headerContent} ) : ( // Nothing to reveal: a plain status row, not a disclosure control.
{headerContent}
)} {status?.guidance ? ( // Guidance the reader needs stays visible in both fold states.
{status.guidance}
) : null} {status && !open && status.preview ? ( // Readable progress under the status line; the line above stays // the one disclosure control, so a click here is a mouse shortcut.
onOpenChange(true) : undefined} > {status.preview ?
{status.preview}
: null}
) : null} {expandable ? ( {/* A nested node indents its revealed rows under the glyph (thread nesting off the parent rail); the top-level turn body owns its own rail. */}
{children}
) : null}
{copyable ? (
) : null}
); } /** * "Working · 2m 14s", "Waiting for 2 agents · 3m 5s", or "Worked for 4m 10s"; * null when a settled span is too short to state. */ function turnSummaryStatusLine(t: OpenGeniText, status: TurnSummaryStatus): ReactNode { if (status.kind === "worked") { return status.durationMs !== undefined && Number.isFinite(status.durationMs) && status.durationMs >= 1000 ? ( {status.label ?? t("timeline.turnSummary.workedFor")} {formatElapsed(t, status.durationMs)} ) : null; } const label = status.label ?? (status.kind === "working" ? t("timeline.turnSummary.working") : t("timeline.turnSummary.waiting")); return ( {status.kind === "working" ? {label} : {label}} {status.since ? : null} ); } /** A second-resolution clock; unmounts with the live row. */ function LiveElapsed({ since }: { since: string }) { const t = useOpenGeniText(); const startedAt = Date.parse(since); const [now, setNow] = useState(() => Date.now()); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); if (!Number.isFinite(startedAt)) return null; return ( {` · ${formatElapsed(t, Math.max(0, now - startedAt))}`} ); } class FacetRenderBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false }; static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; } render(): ReactNode { return this.state.failed ? null : this.props.children; } }