import { KnowledgeReceiptRow } from "./knowledge-receipt"; import { workerRowTitleParts } from "./platform-activity-presentation"; import { AgentRow, AgentRowSection } from "./agent-row"; import { useAgentIdentity } from "./agent-identity"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { GenieLoading } from "./genie-loading"; import { StartupDispatchDetails } from "./startup-dispatch-details"; import { useStartupDetails } from "./startup-preference"; import { ArrowRightIcon, BotIcon, BrainCircuitIcon, MessageSquareTextIcon, SendIcon, } from "lucide-react"; import { Component, createContext, lazy, Suspense, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, } from "react"; import { jsx as rowJsx, jsxs as rowJsxs } from "react/jsx-runtime"; import { Markdown } from "../components/markdown"; import { cn } from "../lib/cn"; import { truncate } from "../lib/format"; import { defaultToolRegistry, rendersToolOutputPreview } from "./tool-renderers"; import { useEntranceAnimation, useEntranceAnimationLive } from "./entrance"; import type { RetainedArtifactLoader, RetainedScreenshotLoader, ToolRegistry } from "./registry"; import { useSeenActivityIds } from "./seen-activity-ids"; import { BodyNote, PayloadBlock, ActivityDisclosure, ToolCallTruncationProvider, ToolOutputPreviewProvider, } from "./shared"; import { invokedToolCall } from "./invoked-tool"; import { toolDisplayName } from "./tool-display-name"; import { ToolKindIcon } from "./tool-icon"; import { genericToolIconKind } from "./tool-presentation"; import type { ActivityItem, AgentMessageItem, MemoryItem, ToolCallItem, WorkerItem } from "./types"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; const LazyFleetDecisionRow = lazy(() => import("./fleet-decision-row")); const LazyPlatformActivityRow = lazy(() => import("./platform-activity-row")); /* ---------------------------------------------------------------------------- Activity rail Renders a run of clustered activity items (reasoning, tool calls, workers, sandbox ops) as the left-bordered column between chat messages. Tool calls resolve through the renderer registry; everything else has a first-class row. Shared by `MessageTimeline` and the component demo so both draw the exact same rail — no divergence. -------------------------------------------------------------------------- */ export type ActivityRailProps = { items: ActivityItem[]; /** The owning turn remains active between individual phase receipts. */ startupActive?: boolean; /** Renderer registry for tool calls. Defaults to {@link defaultToolRegistry}. */ toolRegistry?: ToolRegistry | undefined; /** Drill into a spawned worker session. */ onOpenSession?: ((sessionId: string) => void) | undefined; /** * Deep-link a memory row to its record in the host's memory pane. Opt-in: the * library draws no "View in memory" affordance without a handler (the memory * row is then non-interactive rich content). See {@link MessageTimelineProps}. */ onMemoryClick?: ((memoryId: string) => void) | undefined; loadRetainedScreenshot?: RetainedScreenshotLoader | undefined; /** Resolve permanent generated-image receipts through the authenticated host SDK. */ loadRetainedArtifact?: RetainedArtifactLoader | undefined; /** Drop the left rule + indent (used inside a folded turn summary). */ bare?: boolean | undefined; className?: string | undefined; }; /** * The "family" a row belongs to, for light intra-rail grouping. Consecutive * rows of the same family sit tight; a family change gets a little extra top * margin so a long run reads as clusters rather than one undifferentiated wall. */ function familyOf(item: ActivityItem): string { if (item.kind === "tool-call") { return item.name === "exec_command" || item.name === "write_stdin" ? "terminal" : item.name; } return item.kind; } export function ActivityRail({ items, startupActive, toolRegistry = defaultToolRegistry, onOpenSession, onMemoryClick, loadRetainedScreenshot, loadRetainedArtifact, bare, className, }: ActivityRailProps) { const msg = useOpenGeniText(); const debug = useStartupDetails(); const reducedMotion = useReducedMotion(); const [detailsOpen, setDetailsOpen] = useState(false); const phases = items.filter((item) => item.kind === "startup-phase"); const dispatch = phases.find((item) => item.dispatchWait !== undefined); // Empty reasoning envelopes can arrive before any visible model output. const hasWork = items.some( (item) => item.kind !== "startup-phase" && (item.kind !== "reasoning" || item.text.trim().length > 0), ); const interrupted = phases.some( (item) => item.status === "failed" || item.status === "cancelled", ); const providerResponded = phases.some( (item) => item.phase === "provider_first_byte" && item.status === "complete", ); const responsePending = phases.some((item) => item.phase === "provider_first_byte"); const loading = !hasWork && !interrupted && (startupActive ?? (!providerResponded && phases.some((item) => item.status === "running"))); const visibleItems = debug || detailsOpen ? items : items.filter((item) => item.kind === "startup-phase" ? item.status === "failed" || item.status === "cancelled" : item.kind !== "reasoning" || item.text.trim().length > 0, ); const startedAt = phases.reduce((first, item) => { const start = item.loadingStartedAt ?? item.startedAt; return start < first ? start : first; }, phases[0]?.startedAt ?? ""); const enterMounted = useEntranceAnimation(); // Live gate: rails born during bulk capture enter=false forever; with a // seen-id map we still want later live appends to fade (ids gate remounts). const enterLive = useEntranceAnimationLive(); const seenIds = useSeenActivityIds(); const enter = seenIds ? enterLive : enterMounted; const previousIdsRef = useRef | null>(null); const enteringIds = new Set(); if (enter) { for (const item of items) { if (seenIds) { if (!seenIds.has(item.id)) { enteringIds.add(item.id); } } else if (previousIdsRef.current !== null && !previousIdsRef.current.has(item.id)) { // Standalone ActivityRail (tests / demo): append-only, no remount map. enteringIds.add(item.id); } } } useLayoutEffect(() => { previousIdsRef.current = new Set(items.map((item) => item.id)); if (seenIds) { for (const item of items) { seenIds.add(item.id); } } }); return (
{loading && !debug ? ( setDetailsOpen((open) => !open)} /> ) : null} {detailsOpen && !debug && !loading ? ( ) : null} {dispatch && (detailsOpen || debug) ? ( ) : null} {visibleItems.map((item, index) => { const newFamily = index > 0 && familyOf(item) !== familyOf(visibleItems[index - 1]!); const row = renderActivity( msg, item, toolRegistry, onOpenSession, onMemoryClick, loadRetainedScreenshot, loadRetainedArtifact, ); return (
{row}
); })}
); } /** * Host message renderer for progress notes inside a rail, so notes keep the * host's links and formatting. Without one, notes render the library Markdown. */ export const ActivityNoteTextContext = createContext< ((text: string, item: AgentMessageItem) => ReactNode) | null >(null); /** * Assistant commentary folded into the rail: the agent's own words about the * work, quieter than an answer and aligned with the step titles. */ function ActivityNoteRow({ item }: { item: AgentMessageItem }) { const renderText = useContext(ActivityNoteTextContext); if (!item.text.trim()) return null; return (
{renderText ? ( renderText(item.text, item) ) : ( {item.text} )}
); } /** A never-reachable guard: adding an `ActivityItem` kind is now a compile error. */ function assertNever(item: never): never { throw new Error(`ActivityRail: unhandled activity item ${JSON.stringify(item)}`); } export function renderActivity( msg: OpenGeniText, item: ActivityItem, toolRegistry: ToolRegistry, onOpenSession: ((sessionId: string) => void) | undefined, onMemoryClick: ((memoryId: string) => void) | undefined, loadRetainedScreenshot: RetainedScreenshotLoader | undefined, loadRetainedArtifact: RetainedArtifactLoader | undefined, ) { switch (item.kind) { case "reasoning": case "sandbox": case "startup-phase": return ( toolDisplayName(msg, name)} b={BotIcon} m={Markdown} j={rowJsx} s={rowJsxs} /> ); case "tool-call": { // A deferred tool run through tool_invoke reads as the tool it ran. const shown = invokedToolCall(item); const Renderer = toolRegistry.resolve(shown); const outputPreview = item.outputPreview ?? null; // A row that cannot read its preview waits for its exact output; one // whose read failed, or that has no reader, falls back to the generic // row, which says it shows a preview. const exactFirst = outputPreview !== null && !rendersToolOutputPreview(Renderer, shown); const Shown = exactFirst && (outputPreview.state === "error" || !outputPreview.load) ? toolRegistry.fallback : Renderer; return ( {exactFirst && outputPreview.load && outputPreview.state !== "error" ? ( ) : ( )} ); } case "worker": return ; case "knowledge": return ( ); case "memory": return ; case "agent-message": return ; case "fleet-decision": return ( ); default: return assertNever(item); } } /** * A tool row whose renderer needs its exact output: it reads it on mount and * stands in as a quiet loading row until the output lands. */ function ToolOutputExactRow({ item, preview, }: { item: ToolCallItem; preview: NonNullable; }) { const msg = useOpenGeniText(); const pending = preview.state === "preview"; const load = preview.load; useEffect(() => { if (pending) load?.(); }, [pending, load]); return (
} title={toolDisplayName(msg, item.name, item.display)} preview={msg("timeline.toolRenderers.loadingOutput")} expandable={false} />
); } /** * Human labels for the memory kinds, translated at the SDK boundary so a raw * enum slug never renders as UI. Kept local to the library (the app has its own * `KIND_LABEL`); an unknown kind simply omits the chip rather than showing a slug. */ const MEMORY_KIND_LABEL = (msg: OpenGeniText): Record => ({ preference: msg("timeline.activityRail.preference"), semantic: msg("timeline.activityRail.fact"), procedural: msg("timeline.activityRail.procedure"), decision: msg("timeline.activityRail.decision"), episodic: msg("timeline.activityRail.history"), }); /** * A memory write the agent made mid-turn. A calm, NEUTRAL step (a successful save * is ordinary progress, never an exceptional state, so no accent/color): a brain- * circuit glyph, "Saved to memory" / "Updated memory", a human kind chip, and the * memory text. Expanding reveals the full text; a supersede shows the old text * struck through above the new one. When the host opts in with `onMemoryClick`, * a quiet "View in memory" affordance deep-links to the LIVE record. */ function MemoryRow({ item, onMemoryClick, }: { item: MemoryItem; onMemoryClick?: ((memoryId: string) => void) | undefined; }) { const msg = useOpenGeniText(); const corrected = item.variant === "corrected"; const kindLabel = MEMORY_KIND_LABEL(msg)[item.memoryKind]; // A supersede carries both the old text (`preview`) and the new (`replacementPreview`); // an in-place update / archive carries only `preview`. const superseded = corrected && Boolean(item.replacementPreview); // Link to the LIVE record: a supersede's replacement when present, else the memory itself. const targetId = corrected ? (item.replacementMemoryId ?? item.memoryId) : item.memoryId; const deepLink = Boolean(onMemoryClick); return ( } iconTone="muted" title={ {corrected ? msg("timeline.activityRail.updatedMemory") : msg("timeline.activityRail.savedToMemory")} {kindLabel ? ( {kindLabel} ) : null} } preview={superseded ? item.replacementPreview : item.preview} > {superseded ? ( // The correction as a before → after: the old memory struck through and // dimmed, the new text in the ordinary body weight below it.

{item.preview}

{item.replacementPreview}

) : corrected && item.action === "updated" ? ( // Edited in place, no replacement record: the memory is still live, so // show its current text — NOT the archived treatment. <>

{item.preview}

{msg("timeline.activityRail.updatedInPlace")} ) : corrected ? ( // A correction with no replacement (and not an in-place update) archived the record. {msg("timeline.activityRail.archived")} ) : (

{item.preview}

)} {item.deduped ? ( {msg("timeline.activityRail.mergedIntoAnExistingMemory")} ) : null} {deepLink ? ( ) : null}
); } /** * Spawning or messaging another agent: a named rail step. The title names the * agent, one line previews the brief or message, expanding shows it in full, * and the trailing arrow opens the agent's session once its id is known. A * failed or interrupted call keeps its quiet chip; an interrupted one, or a * spawn that never produced a session, offers no link. */ function WorkerRow({ item, onOpenSession, }: { item: WorkerItem; onOpenSession?: ((sessionId: string) => void) | undefined; }) { const msg = useOpenGeniText(); const identity = useAgentIdentity(); const running = item.status === "running"; const failed = item.status === "failed"; const cancelled = item.status === "cancelled"; const sessionId = item.workerSessionId; const name = identity.titleFor(sessionId) ?? (item.action === "spawn" ? item.title : null); const title = workerRowTitleParts(msg, item, name); const prompt = item.prompt?.trim() ? item.prompt.trim() : null; const failure = failed ? item.failure : null; const hasBody = Boolean(prompt) || Boolean(failure); return ( : } title={title} preview={ failure ? failure.message : prompt ? truncate(prompt.replace(/\s+/g, " "), 240) : null } sessionId={cancelled ? null : sessionId} onOpenSession={onOpenSession ?? identity.onOpenSession} running={running} failed={failed} cancelled={cancelled} > {hasBody ? ( <> {prompt ? ( {prompt} ) : null} {failure ? ( {failure.message} ) : null} ) : undefined} ); } /** * One tool row that fails to render shows a one-line note in its place, so the * rest of the work group (and the live preview) keeps reading normally. */ function ToolRowRenderError({ name }: { name: string }) { const t = useOpenGeniText(); return (
{t("timeline.activityRail.nameCouldNotBeDisplayed", { name })}
); } class ToolRowBoundary extends Component< { name: string; resetKeys: readonly unknown[]; children: ReactNode }, { failed: boolean; resetKeys: readonly unknown[] } > { state = { failed: false, resetKeys: this.props.resetKeys }; static getDerivedStateFromError(): { failed: boolean } { return { failed: true }; } /** A new status or renderer for the row gets a fresh attempt. */ static getDerivedStateFromProps( props: { resetKeys: readonly unknown[] }, state: { failed: boolean; resetKeys: readonly unknown[] }, ): { failed: boolean; resetKeys: readonly unknown[] } | null { const same = props.resetKeys.length === state.resetKeys.length && props.resetKeys.every((key, index) => Object.is(key, state.resetKeys[index])); return same ? null : { failed: false, resetKeys: props.resetKeys }; } render(): ReactNode { if (!this.state.failed) return this.props.children; return ; } }