{
  "name": "checkpoint-timeline",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "checkpoint-timeline.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, keyframes, themeVars as theme } from \"@yugnex/core\";\r\nimport { useCallback, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Model\r\n * ------------------------------------------------------------------ */\r\n\r\nexport type CheckpointKind = \"edit\" | \"command\" | \"plan\" | \"review\" | \"error\" | \"start\";\r\n\r\nexport interface Checkpoint {\r\n  id: string;\r\n  label: string;\r\n  kind?: CheckpointKind;\r\n  /** Epoch milliseconds. Drives the elapsed-time readout. */\r\n  at?: number;\r\n  /** Files touched at this point, shown in the detail line. */\r\n  files?: number;\r\n  additions?: number;\r\n  deletions?: number;\r\n  /** Blocks rewinding to this checkpoint. */\r\n  disabled?: boolean;\r\n  detail?: ReactNode;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Time formatting\r\n * ------------------------------------------------------------------ */\r\n\r\n/** Formats a gap between checkpoints — the useful axis here, not wall clock. */\r\nexport function formatElapsed(ms: number): string {\r\n  if (ms < 1000) return \"0s\";\r\n  const seconds = Math.floor(ms / 1000);\r\n  if (seconds < 60) return `${seconds}s`;\r\n  const minutes = Math.floor(seconds / 60);\r\n  if (minutes < 60) return `${minutes}m ${seconds % 60}s`;\r\n  const hours = Math.floor(minutes / 60);\r\n  return `${hours}h ${minutes % 60}m`;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst pulse = keyframes({\r\n  \"0%, 100%\": { transform: \"scale(1)\", opacity: 1 },\r\n  \"50%\": { transform: \"scale(1.35)\", opacity: 0.55 },\r\n});\r\n\r\nconst rootClass = css({\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  color: theme.color.foreground,\r\n  border: `1px solid ${theme.color.border}`,\r\n  borderRadius: theme.radius.md,\r\n  backgroundColor: theme.color.card,\r\n  padding: theme.space[3],\r\n});\r\n\r\nconst trackWrapClass = css({\r\n  position: \"relative\",\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  padding: `${theme.space[4]} ${theme.space[1]} ${theme.space[2]}`,\r\n  overflowX: \"auto\",\r\n  // Focus lives on the individual nodes; the strip itself must not steal it.\r\n  outline: \"none\",\r\n});\r\n\r\nconst trackClass = css({\r\n  position: \"relative\",\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: 0,\r\n  minWidth: \"100%\",\r\n});\r\n\r\nconst railClass = css({\r\n  position: \"absolute\",\r\n  left: 0,\r\n  right: 0,\r\n  top: \"50%\",\r\n  height: \"2px\",\r\n  transform: \"translateY(-50%)\",\r\n  backgroundColor: theme.color.border,\r\n  borderRadius: \"9999px\",\r\n});\r\n\r\n/**\r\n * The rail is drawn twice: a full-width base and a coloured overlay clipped to\r\n * the current position. Colouring segment-by-segment instead would leave a\r\n * visible seam at every node.\r\n */\r\nconst railDoneClass = css({\r\n  position: \"absolute\",\r\n  left: 0,\r\n  top: \"50%\",\r\n  height: \"2px\",\r\n  transform: \"translateY(-50%)\",\r\n  backgroundColor: theme.color.primary,\r\n  borderRadius: \"9999px\",\r\n  transitionProperty: \"width\",\r\n  transitionDuration: theme.duration.base,\r\n  transitionTimingFunction: theme.easing.standard,\r\n});\r\n\r\nconst nodeWrapClass = css({\r\n  position: \"relative\",\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  alignItems: \"center\",\r\n  flex: 1,\r\n  minWidth: \"3.5rem\",\r\n});\r\n\r\nconst nodeClass = css({\r\n  position: \"relative\",\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  justifyContent: \"center\",\r\n  width: \"1.5rem\",\r\n  height: \"1.5rem\",\r\n  padding: 0,\r\n  borderRadius: \"9999px\",\r\n  border: `2px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.card,\r\n  color: theme.color.mutedForeground,\r\n  cursor: \"pointer\",\r\n  flexShrink: 0,\r\n  transitionProperty: \"border-color, background-color, color, transform\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover:not(:disabled)\": { transform: \"scale(1.12)\", borderColor: theme.color.primary },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"2px\" },\r\n  \"&:disabled\": { opacity: 0.4, cursor: \"not-allowed\" },\r\n  '&[data-state=\"past\"]': {\r\n    borderColor: theme.color.primary,\r\n    backgroundColor: theme.color.primary,\r\n    color: theme.color.primaryForeground,\r\n  },\r\n  '&[data-state=\"current\"]': {\r\n    borderColor: theme.color.primary,\r\n    backgroundColor: theme.color.card,\r\n    color: theme.color.primary,\r\n    boxShadow: `0 0 0 3px ${theme.color.accent}`,\r\n  },\r\n});\r\n\r\nconst currentRingClass = css({\r\n  position: \"absolute\",\r\n  inset: \"-4px\",\r\n  borderRadius: \"9999px\",\r\n  border: `2px solid ${theme.color.primary}`,\r\n  animation: `${pulse} 1.8s ${theme.easing.standard} infinite`,\r\n  pointerEvents: \"none\",\r\n});\r\n\r\nconst nodeLabelClass = css({\r\n  marginTop: theme.space[1.5],\r\n  maxWidth: \"6rem\",\r\n  fontSize: \"0.6875rem\",\r\n  color: theme.color.mutedForeground,\r\n  textAlign: \"center\",\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n  '[data-state=\"current\"] ~ &': { color: theme.color.foreground, fontWeight: theme.fontWeight.medium },\r\n});\r\n\r\nconst detailClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[3],\r\n  marginTop: theme.space[2],\r\n  paddingTop: theme.space[3],\r\n  borderTop: `1px solid ${theme.color.border}`,\r\n  flexWrap: \"wrap\",\r\n});\r\n\r\nconst detailTitleClass = css({ fontWeight: theme.fontWeight.medium, fontSize: theme.fontSize.sm });\r\n\r\nconst detailMetaClass = css({\r\n  display: \"inline-flex\",\r\n  gap: theme.space[2],\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n});\r\n\r\nconst addTextClass = css({ color: theme.color.success });\r\nconst delTextClass = css({ color: theme.color.destructive });\r\n\r\nconst rewindButtonClass = css({\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[1],\r\n  marginLeft: \"auto\",\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[2.5]}`,\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.background,\r\n  color: theme.color.foreground,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.xs,\r\n  fontWeight: theme.fontWeight.medium,\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"background-color, border-color, color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover:not(:disabled)\": { borderColor: theme.color.warning, color: theme.color.warning },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n  \"&:disabled\": { opacity: 0.5, cursor: \"not-allowed\" },\r\n});\r\n\r\nconst confirmClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  marginLeft: \"auto\",\r\n  flexWrap: \"wrap\",\r\n});\r\n\r\nconst confirmTextClass = css({ fontSize: theme.fontSize.xs, color: theme.color.warning });\r\n\r\nconst dangerButtonClass = css({\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[2.5]}`,\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.destructive}`,\r\n  backgroundColor: theme.color.destructive,\r\n  color: theme.color.destructiveForeground,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.xs,\r\n  fontWeight: theme.fontWeight.medium,\r\n  cursor: \"pointer\",\r\n  \"&:hover\": { opacity: 0.92 },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n});\r\n\r\nconst cancelButtonClass = css({\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[2.5]}`,\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.background,\r\n  color: theme.color.foreground,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.xs,\r\n  cursor: \"pointer\",\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n});\r\n\r\nconst emptyClass = css({\r\n  padding: theme.space[4],\r\n  textAlign: \"center\",\r\n  color: theme.color.mutedForeground,\r\n  fontSize: theme.fontSize.sm,\r\n});\r\n\r\nconst KIND_GLYPH: Record<CheckpointKind, string> = {\r\n  start: \"M6 4l5 4-5 4V4z\",\r\n  edit: \"M10.5 2.5l3 3-7 7-3.5.5.5-3.5 7-7z\",\r\n  command: \"M3.5 5l2.5 2.5L3.5 10M8 10.5h4.5\",\r\n  plan: \"M3.5 4h9M3.5 8h9M3.5 12h5\",\r\n  review: \"M3 8.5l3 3 7-7\",\r\n  error: \"M8 4.5v4M8 11h.01M8 14.5A6.5 6.5 0 1 0 8 1.5a6.5 6.5 0 0 0 0 13z\",\r\n};\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface CheckpointTimelineProps {\r\n  checkpoints: Checkpoint[];\r\n  /** Id of the checkpoint the session is currently at. Defaults to the last. */\r\n  current?: string;\r\n  /** Fires when a checkpoint is inspected — selection only, non-destructive. */\r\n  onInspect?: (id: string) => void;\r\n  /**\r\n   * Fires after the user confirms a rewind. Supplying this enables the rewind\r\n   * affordance; omitting it makes the timeline read-only.\r\n   */\r\n  onRewind?: (id: string) => void;\r\n  label?: string;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * A scrubber over an agent session's checkpoints.\r\n *\r\n * Inspecting is separate from rewinding, and rewinding asks first. Moving the\r\n * selection has to be free — that is the whole point of a scrubber — while the\r\n * step that actually discards work is the one thing here that cannot be\r\n * undone, so it gets an explicit confirm rather than firing on click.\r\n */\r\nexport function CheckpointTimeline({\r\n  checkpoints,\r\n  current,\r\n  onInspect,\r\n  onRewind,\r\n  label = \"Session checkpoints\",\r\n  className,\r\n}: CheckpointTimelineProps) {\r\n  const currentIndex = useMemo(() => {\r\n    if (current) {\r\n      const found = checkpoints.findIndex((c) => c.id === current);\r\n      if (found !== -1) return found;\r\n    }\r\n    return checkpoints.length - 1;\r\n  }, [checkpoints, current]);\r\n\r\n  const [inspectedIndex, setInspectedIndex] = useState(currentIndex);\r\n  const [confirming, setConfirming] = useState(false);\r\n  const nodeRefs = useRef<Array<HTMLButtonElement | null>>([]);\r\n\r\n  // Clamp against a shrinking list rather than reading past the end.\r\n  const safeIndex = Math.min(Math.max(inspectedIndex, 0), Math.max(checkpoints.length - 1, 0));\r\n  const inspected = checkpoints[safeIndex];\r\n\r\n  const select = useCallback(\r\n    (index: number) => {\r\n      const clamped = Math.min(Math.max(index, 0), checkpoints.length - 1);\r\n      setInspectedIndex(clamped);\r\n      // Any move invalidates a pending confirm — otherwise the user could aim\r\n      // at one checkpoint, move, and confirm a rewind to a different one.\r\n      setConfirming(false);\r\n      nodeRefs.current[clamped]?.focus();\r\n      const target = checkpoints[clamped];\r\n      if (target) onInspect?.(target.id);\r\n    },\r\n    [checkpoints, onInspect],\r\n  );\r\n\r\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\r\n    switch (event.key) {\r\n      case \"ArrowRight\":\r\n        event.preventDefault();\r\n        select(index + 1);\r\n        break;\r\n      case \"ArrowLeft\":\r\n        event.preventDefault();\r\n        select(index - 1);\r\n        break;\r\n      case \"Home\":\r\n        event.preventDefault();\r\n        select(0);\r\n        break;\r\n      case \"End\":\r\n        event.preventDefault();\r\n        select(checkpoints.length - 1);\r\n        break;\r\n      default:\r\n        break;\r\n    }\r\n  };\r\n\r\n  if (checkpoints.length === 0) {\r\n    return (\r\n      <div className={className ? `${rootClass} ${className}` : rootClass} aria-label={label}>\r\n        <div className={emptyClass}>No checkpoints yet.</div>\r\n      </div>\r\n    );\r\n  }\r\n\r\n  const first = checkpoints[0];\r\n  const elapsed =\r\n    inspected?.at !== undefined && first?.at !== undefined ? formatElapsed(inspected.at - first.at) : undefined;\r\n\r\n  // The done-rail spans node centres, and nodes are evenly distributed, so the\r\n  // centre of node i sits at (i + 0.5) / n of the track.\r\n  const donePercent =\r\n    checkpoints.length > 1 ? ((safeIndex + 0.5) / checkpoints.length) * 100 : 50;\r\n\r\n  const canRewind = Boolean(onRewind) && safeIndex < currentIndex && !inspected?.disabled;\r\n\r\n  return (\r\n    <div className={className ? `${rootClass} ${className}` : rootClass}>\r\n      <div className={trackWrapClass}>\r\n        <div className={trackClass} role=\"group\" aria-label={label}>\r\n          <span className={railClass} aria-hidden=\"true\" />\r\n          <span className={railDoneClass} style={{ width: `${donePercent}%` }} aria-hidden=\"true\" />\r\n\r\n          {checkpoints.map((checkpoint, index) => {\r\n            const state = index < safeIndex ? \"past\" : index === safeIndex ? \"current\" : \"future\";\r\n            const glyph = KIND_GLYPH[checkpoint.kind ?? \"edit\"];\r\n\r\n            return (\r\n              <span key={checkpoint.id} className={nodeWrapClass}>\r\n                <button\r\n                  type=\"button\"\r\n                  ref={(el) => {\r\n                    nodeRefs.current[index] = el;\r\n                  }}\r\n                  className={nodeClass}\r\n                  data-state={state}\r\n                  // Roving tabindex: the strip is one tab stop.\r\n                  tabIndex={index === safeIndex ? 0 : -1}\r\n                  aria-current={index === safeIndex ? \"step\" : undefined}\r\n                  aria-label={`${checkpoint.label}${index === currentIndex ? \" (current session state)\" : \"\"}`}\r\n                  disabled={checkpoint.disabled}\r\n                  onClick={() => select(index)}\r\n                  onKeyDown={(event) => onKeyDown(event, index)}\r\n                >\r\n                  {index === currentIndex && index !== safeIndex ? (\r\n                    <span className={currentRingClass} aria-hidden=\"true\" />\r\n                  ) : null}\r\n                  <svg width=\"11\" height=\"11\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\">\r\n                    <path\r\n                      d={glyph}\r\n                      stroke=\"currentColor\"\r\n                      strokeWidth=\"1.75\"\r\n                      strokeLinecap=\"round\"\r\n                      strokeLinejoin=\"round\"\r\n                    />\r\n                  </svg>\r\n                </button>\r\n                <span className={nodeLabelClass}>{checkpoint.label}</span>\r\n              </span>\r\n            );\r\n          })}\r\n        </div>\r\n      </div>\r\n\r\n      {inspected ? (\r\n        <div className={detailClass}>\r\n          <span className={detailTitleClass}>{inspected.label}</span>\r\n\r\n          <span className={detailMetaClass}>\r\n            {elapsed ? <span>+{elapsed}</span> : null}\r\n            {inspected.files !== undefined ? (\r\n              <span>\r\n                {inspected.files} {inspected.files === 1 ? \"file\" : \"files\"}\r\n              </span>\r\n            ) : null}\r\n            {inspected.additions !== undefined ? (\r\n              <span className={addTextClass}>+{inspected.additions}</span>\r\n            ) : null}\r\n            {inspected.deletions !== undefined ? (\r\n              <span className={delTextClass}>−{inspected.deletions}</span>\r\n            ) : null}\r\n          </span>\r\n\r\n          {inspected.detail}\r\n\r\n          {confirming ? (\r\n            <span className={confirmClass}>\r\n              <span className={confirmTextClass} role=\"alert\">\r\n                Discard everything after this point?\r\n              </span>\r\n              <button type=\"button\" className={cancelButtonClass} onClick={() => setConfirming(false)}>\r\n                Cancel\r\n              </button>\r\n              <button\r\n                type=\"button\"\r\n                className={dangerButtonClass}\r\n                onClick={() => {\r\n                  setConfirming(false);\r\n                  onRewind?.(inspected.id);\r\n                }}\r\n              >\r\n                Rewind\r\n              </button>\r\n            </span>\r\n          ) : onRewind ? (\r\n            <button\r\n              type=\"button\"\r\n              className={rewindButtonClass}\r\n              disabled={!canRewind}\r\n              title={\r\n                safeIndex === currentIndex\r\n                  ? \"Already at this checkpoint\"\r\n                  : safeIndex > currentIndex\r\n                    ? \"Cannot rewind forward\"\r\n                    : undefined\r\n              }\r\n              onClick={() => setConfirming(true)}\r\n            >\r\n              <svg width=\"12\" height=\"12\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden=\"true\">\r\n                <path\r\n                  d=\"M2 7a5 5 0 1 0 1.5-3.5M2 1.5V4h2.5\"\r\n                  stroke=\"currentColor\"\r\n                  strokeWidth=\"1.5\"\r\n                  strokeLinecap=\"round\"\r\n                  strokeLinejoin=\"round\"\r\n                />\r\n              </svg>\r\n              Rewind here\r\n            </button>\r\n          ) : null}\r\n        </div>\r\n      ) : null}\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}