import { CameraIcon, CameraOffIcon, ChevronRightIcon } from "lucide-react"; import { useToolDetails } from "./tool-details"; import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import { useTimelineSearchReveal } from "../components/timeline-search"; import { cn } from "../lib/cn"; import { stringifyPayload } from "../lib/format"; import { SecretRefText } from "../components/secret-ref-chip"; import { useForcedDefaultOpen } from "./disclosure-context"; import { useLightboxOptional } from "./screenshot-lightbox"; import type { ToolCallTruncation, ToolOutputPreview } from "./types"; import { useOpenGeniText } from "../messages/runtime"; import { ShimmerText } from "./shimmer-text"; /* ---------------------------------------------------------------------------- Shared timeline primitives The restraint layer. One disclosure shape every tool renderer reuses, so the rail reads as a calm, aligned column: a chevron, a tinted icon, a title, an optional muted preview, and — at most — ONE quiet right-aligned signal (a settle chip). Compact by default; the body only mounts when expanded. CHIP DOCTRINE (closed set — do not extend): The right gutter carries at most ONE terse status token per row, and COLOR is spent only on the exception. Success is the default, so it never earns a hue: a settled-ok chip is bare muted text (or nothing). The colored dot is reserved for failure alone. In-flight state is NOT a gutter chip — the shimmering title carries it, so a running row has a clean right edge (no detached pulse badge). There is no bordered/filled pill. Anything narrative (a session id, "approval rejected", "malformed V4A") belongs in the muted preview line, never the gutter. ok a settled success quiet muted text ("0", "done") — no dot bad a settled failure red dot + red text ("exit 6") — the one hue muted quiet metadata subtle text ("session 3") -------------------------------------------------------------------------- */ const ToolOutputPreviewContext = createContext(null); /** * Marks a tool row whose output is a bounded preview. The row's disclosure * reads the exact output when it opens and shows it once it lands; until then * the body says it is loading, and a failed read says it is a preview. */ export function ToolOutputPreviewProvider({ value, children, }: { value: ToolOutputPreview | null; children: ReactNode; }) { return ( {children} ); } /** The row's output preview, when its output is not exact. */ export function useToolOutputPreview(): ToolOutputPreview | null { return useContext(ToolOutputPreviewContext); } /** * A subtle settle signal — see the CHIP DOCTRINE above. The closed tone set. * `"interrupted"` is a calm neutral tone for cancelled items — no dot, same * quiet weight as `"muted"`, but semantically distinct from metadata. */ export type DisclosureChip = { tone: "ok" | "bad" | "muted" | "interrupted"; text: string; }; const ToolCallTruncationContext = createContext(null); export function ToolCallTruncationProvider({ value, children, }: { value: ToolCallTruncation | null; children: ReactNode; }) { return ( {children} ); } export type ActivityDisclosureProps = { /** Override the rolling preview; null keeps rapidly streaming details out of the header. */ compactPreview?: ReactNode; icon: ReactNode; /** Icon tint. Defaults to the muted foreground; renderers pass accent/failed. */ iconTone?: "accent" | "failed" | "running" | "muted" | undefined; title: ReactNode; /** Render the title in the mono face (commands, paths). */ titleMono?: boolean | undefined; /** Shimmer the title while the tool is in-flight. */ running?: boolean | undefined; /** * Quiet single-line secondary text (truncated). It is detail-on-demand: hidden * when a media preview is set, AND hidden once the row is expanded (the body * then owns the detail), so a stat/path never appears twice at once. */ preview?: ReactNode | undefined; /** A small inline media preview (a screenshot thumbnail) shown in place of `preview`. */ media?: ReactNode | undefined; /** At most one quiet settle chip, right-aligned to the gutter. */ chip?: DisclosureChip | undefined; /** * When true the row carries the standard failure affordance: the icon is tinted * red and a "failed" bad-chip appears in the right gutter (unless an explicit * `chip` is already supplied — the caller's chip wins). Output is still visible * on expand; this is a quiet status signal, not a blocking banner. * * Renderers should pass `failed={item.status === "failed"}` on their settled * (non-running) paths so any tool with a failed status shows a consistent * affordance without each renderer having to duplicate the logic. */ failed?: boolean | undefined; /** * When true the row carries a calm "interrupted" affordance: the icon stays * muted (no red) and a quiet "interrupted" chip appears in the right gutter * (unless an explicit `chip` is already supplied — the caller's chip wins). * This is the cancelled-status analogue of `failed`, but deliberately calm * and neutral — it is NOT an error; the user chose to stop. * * Renderers should pass `cancelled={item.status === "cancelled"}` so any * in-flight item that was interrupted on turn.cancelled reads consistently. * `cancelled` is ignored when `failed` is also true (failure takes precedence). */ cancelled?: boolean | undefined; /** When false the row is a static line (no expand affordance). */ expandable?: boolean | undefined; /** Seed this individual disclosure open; user interaction still owns it afterwards. */ defaultOpen?: boolean | undefined; children?: ReactNode | undefined; }; const ICON_TONE: Record, string> = { accent: "text-og-accent", failed: "text-og-status-failed", running: "text-og-status-running", muted: "text-og-fg-subtle", }; /** * The one disclosure row shape every activity row reuses (tool calls, reasoning, * sandbox ops): a chevron, a tinted icon, a title, an optional muted preview or * inline media, and at most one right-gutter settle chip. Compact by default; * the body mounts only when expanded. */ export const CompactActivityContext = createContext(false); export function ActivityDisclosure({ icon, iconTone: iconToneProp = "muted", title, titleMono, running, preview, compactPreview, media, chip: chipProp, failed, cancelled, expandable = true, defaultOpen, children, }: ActivityDisclosureProps) { const t = useOpenGeniText(); // `failed` takes precedence over `cancelled` when both are set (shouldn't happen, but be safe). // When `failed` is set the icon goes red and a "failed" chip appears in the // gutter — unless the caller already supplied an explicit chip (their chip wins, // e.g. an exit-code chip that is more informative than a bare "failed" label). // When `cancelled` is set (and not failed) the icon stays muted and a calm // "interrupted" chip appears — no red, just a quiet neutral signal. const iconTone = failed && iconToneProp === "muted" ? "failed" : iconToneProp; const chip = chipProp ?? (failed ? ({ tone: "bad", text: t("timeline.chip.failed") } satisfies DisclosureChip) : cancelled ? ({ tone: "interrupted", text: t("timeline.chip.interrupted") } satisfies DisclosureChip) : undefined); // An ancestor may seed the initial open state (screenshot instrumentation); // absent in normal app usage, where the row starts collapsed. const forcedDefaultOpen = useForcedDefaultOpen(); const [open, setOpen] = useState(defaultOpen ?? forcedDefaultOpen ?? false); const searchReveal = useTimelineSearchReveal(); useEffect(() => { if (searchReveal) setOpen(true); }, [searchReveal]); // A truncation notice is about raw output, which plain tool details never show. const plain = useToolDetails() === "plain"; const truncationContext = useContext(ToolCallTruncationContext); const truncation = plain ? null : truncationContext; const compact = useContext(CompactActivityContext); // A previewed output reads its exact output when the row opens (once: a // failed read waits for Retry). const outputPreview = useContext(ToolOutputPreviewContext); const loadOutput = outputPreview?.load; const outputPending = outputPreview?.state === "preview"; useEffect(() => { if (open && outputPending && !compact) loadOutput?.(); }, [open, outputPending, loadOutput, compact]); if (compact) return ( {icon} {title} {(compactPreview === undefined ? preview : compactPreview) ? ( {compactPreview === undefined ? preview : compactPreview} ) : null} ); const hasBody = expandable && (children != null || truncation != null); // The preview is detail-on-demand: it is suppressed once the row is open so a // path/stat shown in the body never also sits in the collapsed row. const previewVisible = preview != null && !open; // ONE full-row layout for EVERY tool — media or not — so the hit target always // equals the visible row. The chevron → icon → title lead; a flex spacer (or // the preview) fills the middle; the right gutter (ml-auto) carries the chip OR // the media thumbnail. There is no media-vs-non-media fork: the screenshot card // toggles from anywhere on the row, exactly like every other row. // // The row is the single Collapsible.Trigger (via `asChild` onto a div), so it // is the toggle surface. A div — not a native ) : null} ); } function ToolCallTruncationNotice({ truncation }: { truncation: ToolCallTruncation }) { const t = useOpenGeniText(); const fullEvidence = truncation.fullEvidence.available ? "retained" : (truncation.fullEvidence.reason ?? "unavailable"); return (
{t("timeline.shared.outputBoundedAt")}{" "} {truncation.surface} {truncation.omittedBytes != null && truncation.omittedBytes > 0 ? ` · ${t("timeline.shared.bytesOmitted", { count: truncation.omittedBytes, bytes: truncation.omittedBytes.toLocaleString(t.locale) })}` : null} {` · ${t("timeline.shared.truncationReason")} `} {truncation.reason} {` · ${t("timeline.shared.fullEvidence")} `} {fullEvidence}
); } /** * The right-gutter settle chip. Color is spent only on the exception: a failure * is a red dot + red text (the one colored token in a healthy run); success and * metadata are bare muted text with no dot. No box, no fill (see the CHIP * DOCTRINE above). A `bad` chip with empty text is a lone dot (no trailing void). */ function Chip({ chip }: { chip: DisclosureChip }) { const base = "inline-flex items-center font-og-mono text-og-xs leading-none"; const withText = chip.text ? "gap-1.5" : "gap-0"; if (chip.tone === "bad") { return ( {chip.text} ); } // "interrupted" is a calm cancelled signal: same quiet weight as muted, no // dot, no red — just a slightly more prominent subtle text so "interrupted" // reads at a glance without demanding attention the way a failure does. if (chip.tone === "interrupted") { return {chip.text}; } // ok and muted are the same quiet weight — success never earns a hue. return {chip.text}; } /* --- terminal output block (exec / write_stdin) ---------------------------- */ type TermBlockProps = Parameters[0]; /** Terminal output under a tool row; plain tool details never show it. */ export function TermBlock(props: TermBlockProps) { return useToolDetails() === "plain" ? null : ; } function TermBlockView({ command, workdir, output, live, tailLines = 12, failed, }: { /** * The command shown in the prompt header. Pass `null` when the row title * already carries it (e.g. an exec row titled `$ cmd`): the header then drops * the command — and the whole prompt line if there is no workdir either — so * the command never reads twice, stacked, above the output. */ command: string | null; workdir?: string | null | undefined; /** The FULL output. TermBlock owns the tail/full slicing internally. */ output: string; live?: boolean | undefined; /** A non-zero exit / failed call — tints the left accent red (the one hue). */ failed?: boolean | undefined; /** * When the output exceeds the tail window, only the last `tailLines` are shown * with a "show full output" toggle. The component holds the full text, so the * toggle reveals the rest (never a dead affordance). Defaults to 12. */ tailLines?: number | undefined; }) { const t = useOpenGeniText(); const [full, setFull] = useState(false); const empty = output.trim() === ""; const lines = output.split("\n"); const big = lines.length > tailLines + 4; const shown = full || !big ? output : lines.slice(-tailLines).join("\n"); const showMore = big && !full; const showHeader = command != null || workdir != null; // No frame, no fill: a quiet monospace run flush on the page, marked only by a // 2px left accent so it reads as terminal output at a glance without becoming // yet another nested box. Color is spent only on the exception — a settled run // gets a calm neutral rule, a live one the running hue, a failed one the red. return (
{showHeader ? (
$ {command != null ? ( ) : ( )} {workdir ? ( {workdir} ) : null}
) : null} {empty ? (

{t("timeline.shared.noOutput")}

) : (
          {shown}
          {live ? (
            
          ) : null}
        
)} {showMore ? ( ) : null}
); } /* --- generic payload block -------------------------------------------------- */ export function PayloadBlock({ label, value, failed, }: { label: string; value: unknown; failed?: boolean | undefined; }) { const plain = useToolDetails() === "plain"; const text = typeof value === "string" ? value : stringifyPayload(value); // Raw arguments and output are developer detail: plain tool details never show them. if (plain || !text || text.trim() === "") { return null; } // Flush on the page, no frame — a labelled monospace run marked only by a 2px // left accent (red when the call failed, otherwise a calm neutral rule), so a // payload reads as detail hanging off the row, never a nested card. return (

{label}

        
      
); } /** A quiet inline note inside an expanded body (lost output, empty frame, …). */ export function BodyNote({ children, tone, }: { children: ReactNode; tone?: "error" | "muted" | undefined; }) { if (tone === "error") { // A quiet error run marked by a 2px red accent — the same flush, frameless // language as the payload/output blocks, not a filled callout box. return (
{children}
); } return

{children}

; } /* --- screenshot thumbnail + media states ------------------------------------ */ /** * The shared inline-media footprint, so thumb / skeleton / empty align. Sized to * the row's line height (~28px) so a media row never out-weighs a text row and * the single-column rhythm holds. */ const MEDIA_BOX = "h-7 w-[52px] shrink-0 rounded-og-xs border border-og-border"; /** * A loading screenshot placeholder. A faint camera glyph over a shimmering box, * so a still frame of the running state reads unambiguously as "capturing" — not * a broken thumbnail. */ export function MediaSkeleton() { return ( ); } /** A standardized "tool ran, produced no image" placeholder in the media slot. */ export function MediaEmpty() { return ( ); } /** * A small inline screenshot thumbnail that opens the app lightbox on click. * * Requires a `LightboxProvider` ancestor for the click-to-expand affordance. * Outside one it degrades to a plain, non-interactive image — never a dead * "Expand" button that announces an action it cannot perform. */ export function Thumbnail({ src, caption, alt = "screenshot", expandLabel: expandLabelProp, lightboxLabel: lightboxLabelProp, downloadFilename, }: { src: string; caption?: string | undefined; alt?: string; expandLabel?: string | undefined; lightboxLabel?: string | undefined; downloadFilename?: string | undefined; }) { const t = useOpenGeniText(); const expandLabel = expandLabelProp === undefined ? t("timeline.shared.expandScreenshot") : expandLabelProp; const lightboxLabel = lightboxLabelProp === undefined ? t("timeline.shared.screenshot") : lightboxLabelProp; const lightbox = useLightboxOptional(); const [failed, setFailed] = useState(false); if (failed) { return ; } // A plain (not a framework Image): this is a framework-agnostic SDK, so // the host's image component is unavailable and unwanted here. const img = ( {alt} setFailed(true)} className="h-full w-full object-cover transition-opacity group-hover/thumb:opacity-80" /> ); if (!lightbox) { return {img}; } // The thumbnail is a real, independently-focusable button nested inside the // row's disclosure trigger. It stops both pointer AND keyboard activation from // bubbling, so opening the lightbox never also toggles the row. return ( ); } /** * The expanded screenshot inside a tool body: a contained, clickable preview * (opens the lightbox) with a quiet caption. Constrained height + object-contain * so it never breaks the row layout. Like {@link Thumbnail}, it degrades to a * plain image outside a `LightboxProvider`. */ export function ScreenshotFigure({ src, caption, showCaption = true, alt = "screenshot", expandLabel: expandLabelProp, lightboxLabel: lightboxLabelProp, downloadFilename, }: { src: string; caption?: string | undefined; /** Off when the row above already says the same; the lightbox keeps it. */ showCaption?: boolean | undefined; alt?: string; expandLabel?: string | undefined; lightboxLabel?: string | undefined; downloadFilename?: string | undefined; }) { const t = useOpenGeniText(); const expandLabel = expandLabelProp === undefined ? t("timeline.shared.expandScreenshot") : expandLabelProp; const lightboxLabel = lightboxLabelProp === undefined ? t("timeline.shared.screenshot") : lightboxLabelProp; const lightbox = useLightboxOptional(); const [failed, setFailed] = useState(false); const surface = "block w-full overflow-hidden rounded-og-md border border-og-border bg-og-bg"; // A plain , like {@link Thumbnail} — a framework-agnostic SDK has no host // Image component to defer to. const img = ( {alt} setFailed(true)} className="max-h-80 w-full object-contain" /> ); return (
{failed ? (
{t("timeline.shared.imageUnavailable")}
) : lightbox ? ( ) : (
{img}
)} {caption && showCaption ? (
{caption}
) : null}
); }