{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "browser-session",
  "type": "registry:ui",
  "title": "Browser Session",
  "description": "A computer/browser-use session made legible: tidy browser frame, the page, the agent's cursor with click ripple and target box, and a step log with take-over.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/button.json",
    "https://kuro.abstergo.fyi/r/kura-format.json",
    "https://kuro.abstergo.fyi/r/shimmer-text.json",
    "https://kuro.abstergo.fyi/r/status-glyph.json",
    "https://kuro.abstergo.fyi/r/use-elapsed.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/browser-session/browser-session.tsx",
      "type": "registry:ui",
      "target": "components/kura/browser-session/browser-session.tsx",
      "content": "\"use client\";\n\nimport { Hand, Lock, Play } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { cn } from \"@/lib/utils\";\nimport { formatClock } from \"@/lib/kura/format\";\nimport { Button } from \"@/components/kura/button\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\n\nexport type BrowserStepKind = \"navigate\" | \"click\" | \"type\" | \"scroll\" | \"extract\";\n\nexport interface BrowserStep {\n  /** Stable key. */\n  id: string;\n  kind: BrowserStepKind;\n  /** What the agent does, in words: \"Click “Create pull request”\". */\n  label: React.ReactNode;\n  status: AgentStatus;\n  /** Machine detail in mono: the URL, the typed text, the selector, the extracted value. */\n  detail?: React.ReactNode;\n}\n\nexport interface BrowserPoint {\n  /** Percent of the viewport width, 0–100. */\n  x: number;\n  /** Percent of the viewport height, 0–100. */\n  y: number;\n}\n\nexport interface BrowserHighlight extends BrowserPoint {\n  /** Percent of the viewport width. */\n  width: number;\n  /** Percent of the viewport height. */\n  height: number;\n  /** Short tag over the box, e.g. `button \"Create pull request\"`. */\n  label?: string;\n}\n\nexport interface BrowserSessionProps extends Omit<React.ComponentProps<\"div\">, \"title\"> {\n  /** The page URL shown in the address bar. */\n  url: string;\n  /** The page title shown next to the address bar. */\n  title?: string;\n  /** Viewport content: a live page, a mock rendered as markup, or anything else. */\n  children?: React.ReactNode;\n  /** A screenshot to show instead of `children`. */\n  src?: string;\n  /** Alt text for `src`. Default: \"Screenshot of {url}\". */\n  alt?: string;\n  /**\n   * Design size of the page in CSS px (e.g. the agent's 1280×800 screen). `children` render at this\n   * size and scale to fit, so coordinates stay stable at any width. Omit to let children flow.\n   */\n  viewport?: { width: number; height: number };\n  /** Viewport aspect ratio when `viewport` is not given. Default 16 / 10. */\n  aspectRatio?: number;\n  /** Vertical scroll of the page in viewport px (with `viewport`) or CSS px. Eases on change. */\n  scrollTop?: number;\n  /** The agent's cursor, in % of the viewport. Glides between positions. */\n  cursor?: BrowserPoint | null;\n  /** Name tag on the cursor (e.g. the agent's name). */\n  cursorLabel?: string;\n  /** Change this to play a click ripple at the cursor. Default: the active step's id when it is a click. */\n  clickKey?: string | number;\n  /** Box around the element the agent is acting on, in % of the viewport. */\n  highlight?: BrowserHighlight | null;\n  /** The step log. Omit to hide the log. */\n  steps?: readonly BrowserStep[];\n  /** Id of the step being executed. Default: the first running step. */\n  activeStep?: string;\n  /** Session state. browsing (blue), paused for the human (amber), done, failed. Default \"browsing\". */\n  status?: \"browsing\" | \"paused\" | \"done\" | \"failed\";\n  /** Override the status line, e.g. \"Paused — enter the 2FA code\". */\n  statusText?: React.ReactNode;\n  /** Wall-clock start of the session, for the elapsed clock. */\n  startedAt?: Date | number;\n  /** Show the page-loading bar under the address bar. Default: the active step is a running navigate. */\n  loading?: boolean;\n  /** Extra controls in the status line, before Take over / Resume. */\n  actions?: React.ReactNode;\n  /** Shows \"Take over\" while browsing. */\n  onTakeOver?: () => void;\n  /** Shows \"Resume agent\" while paused. */\n  onResume?: () => void;\n  /** Step log beside the browser, below it, or beside only when the container is wide. Default \"auto\". */\n  layout?: \"auto\" | \"side\" | \"stacked\";\n}\n\nconst STATUS_GLYPH: Record<NonNullable<BrowserSessionProps[\"status\"]>, AgentStatus> = {\n  browsing: \"running\",\n  paused: \"waiting\",\n  done: \"done\",\n  failed: \"failed\",\n};\n\nconst STATUS_TEXT: Record<NonNullable<BrowserSessionProps[\"status\"]>, string> = {\n  browsing: \"Agent is browsing\",\n  paused: \"Paused for you\",\n  done: \"Session ended\",\n  failed: \"Session failed\",\n};\n\nconst STEP_SPOKEN: Record<AgentStatus, string> = {\n  queued: \"pending\",\n  running: \"in progress\",\n  waiting: \"waiting for you\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n  paused: \"paused\",\n};\n\nfunction splitUrl(url: string): { secure: boolean; host: string; rest: string } {\n  const m = /^(https?:\\/\\/)?([^/?#]+)(.*)$/.exec(url);\n  return {\n    secure: !url.startsWith(\"http://\"),\n    host: m?.[2] ?? url,\n    rest: m?.[3] ?? \"\",\n  };\n}\n\nfunction useScale(ref: React.RefObject<HTMLDivElement | null>, width: number | undefined) {\n  const [scale, setScale] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || !width) return;\n    const update = () => setScale(el.clientWidth / width);\n    update();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, width]);\n  return scale;\n}\n\nfunction Cursor({ point, label }: { point: BrowserPoint; label?: string }) {\n  return (\n    <span\n      data-slot=\"browser-session-cursor\"\n      className=\"absolute z-30 transition-[left,top] duration-(--kura-duration-slower) ease-kura-in-out motion-reduce:transition-none\"\n      style={{ left: `${point.x}%`, top: `${point.y}%` }}\n    >\n      <svg\n        aria-hidden\n        width=\"16\"\n        height=\"20\"\n        viewBox=\"0 0 16 20\"\n        className=\"-translate-x-[2px] -translate-y-[1px] drop-shadow-[0_1px_1.5px_oklch(0_0_0/0.25)]\"\n      >\n        <path\n          d=\"M1.5 1.5v14.2l3.7-3.5 2.5 5.8 2.5-1.1-2.4-5.6h5.2L1.5 1.5Z\"\n          className=\"fill-kura-fg stroke-kura-surface\"\n          strokeWidth=\"1.25\"\n          strokeLinejoin=\"round\"\n        />\n      </svg>\n      {label && (\n        <span className=\"absolute top-[15px] left-[11px] whitespace-nowrap rounded-[5px] bg-kura-accent px-1.5 py-px font-medium text-[10.5px] text-kura-accent-fg leading-4 shadow-kura-xs\">\n          {label}\n        </span>\n      )}\n    </span>\n  );\n}\n\n/** One click ripple; replays when its key changes. Waits for the cursor to arrive. */\nfunction Ripple({ point }: { point: BrowserPoint }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof el.animate !== \"function\") return;\n    if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) return;\n    const animation = el.animate(\n      [\n        { transform: \"translate(-50%, -50%) scale(0.2)\", opacity: 1 },\n        { transform: \"translate(-50%, -50%) scale(1)\", opacity: 0 },\n      ],\n      { duration: 700, delay: 420, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\", fill: \"forwards\" },\n    );\n    return () => animation.cancel();\n  }, []);\n  return (\n    <span\n      ref={ref}\n      data-slot=\"browser-session-ripple\"\n      className=\"absolute z-20 size-10 rounded-full border-2 border-kura-accent opacity-0\"\n      style={{ left: `${point.x}%`, top: `${point.y}%`, transform: \"translate(-50%, -50%)\" }}\n    />\n  );\n}\n\nfunction StepRow({ step, active }: { step: BrowserStep; active: boolean }) {\n  return (\n    <li\n      data-slot=\"browser-session-step\"\n      data-kind={step.kind}\n      data-status={step.status}\n      data-active={active || undefined}\n      aria-current={active ? \"step\" : undefined}\n      className={cn(\n        \"relative flex items-start gap-2.5 border-kura-border-subtle border-t px-3.5 py-2 first:border-t-0\",\n        \"transition-[opacity,translate,background-color] duration-(--kura-duration-base) ease-kura-out starting:translate-y-[2px] starting:opacity-0 motion-reduce:starting:translate-y-0\",\n        active && \"bg-kura-hover\",\n      )}\n    >\n      <span className=\"flex h-5 shrink-0 items-center\">\n        <StatusGlyph status={step.status} size=\"sm\" label={null} />\n      </span>\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span\n          className={cn(\n            \"text-[13px] leading-5\",\n            step.status === \"queued\" ? \"text-kura-fg-muted\" : \"text-kura-fg\",\n            step.status === \"running\" && \"font-medium\",\n            step.status === \"skipped\" && \"text-kura-fg-subtle line-through\",\n          )}\n        >\n          {step.status === \"running\" ? (\n            <ShimmerText duration={2.4}>{step.label}</ShimmerText>\n          ) : (\n            step.label\n          )}\n          <span className=\"sr-only\"> ({STEP_SPOKEN[step.status]})</span>\n        </span>\n        {step.detail != null && (\n          <span\n            className={cn(\n              \"truncate font-kura-mono text-[11.5px] leading-4\",\n              step.status === \"failed\" ? \"text-kura-danger-text\" : \"text-kura-fg-subtle\",\n            )}\n          >\n            {step.detail}\n          </span>\n        )}\n      </span>\n      <span className=\"flex h-5 shrink-0 items-center font-kura-mono text-[11px] text-kura-fg-subtle\">\n        {step.kind}\n      </span>\n    </li>\n  );\n}\n\n/**\n * A computer-use / browser-use session made legible: a tidy browser frame (title + address bar with\n * lock), the page itself (your markup or a screenshot, optionally at a fixed design size that scales\n * to fit), an action overlay (the agent's cursor gliding between targets, a click ripple, a box around\n * the target element) and a step log of navigate / click / type / scroll / extract actions, with a\n * status line and take-over / resume controls.\n */\nexport function BrowserSession({\n  url,\n  title,\n  children,\n  src,\n  alt,\n  viewport,\n  aspectRatio = 16 / 10,\n  scrollTop = 0,\n  cursor,\n  cursorLabel,\n  clickKey,\n  highlight,\n  steps,\n  activeStep,\n  status = \"browsing\",\n  statusText,\n  startedAt,\n  loading,\n  actions,\n  onTakeOver,\n  onResume,\n  layout = \"auto\",\n  className,\n  ...props\n}: BrowserSessionProps) {\n  const frameRef = React.useRef<HTMLDivElement>(null);\n  const logRef = React.useRef<HTMLOListElement>(null);\n  const scale = useScale(frameRef, viewport?.width);\n  const elapsed = useElapsed(startedAt, status === \"browsing\", 1000);\n\n  const active =\n    steps?.find((s) => s.id === activeStep) ?? steps?.find((s) => s.status === \"running\");\n  const ripple = clickKey ?? (active?.kind === \"click\" ? active.id : undefined);\n  const isLoading = loading ?? (active?.kind === \"navigate\" && active.status === \"running\");\n  const done = steps?.filter((s) => s.status === \"done\" || s.status === \"skipped\").length ?? 0;\n  const { secure, host, rest } = splitUrl(url);\n  const ratio = viewport ? viewport.width / viewport.height : aspectRatio;\n\n  // Keep the active step in view inside the log (without scrolling the page).\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the active step changes\n  React.useEffect(() => {\n    const list = logRef.current;\n    const scroller = list?.parentElement;\n    const row = list?.querySelector<HTMLElement>(\"[data-active]\");\n    if (!scroller || !row) return;\n    const top = row.offsetTop - list.offsetTop;\n    if (\n      top < scroller.scrollTop ||\n      top + row.offsetHeight > scroller.scrollTop + scroller.clientHeight\n    )\n      scroller.scrollTop = top - scroller.clientHeight / 2 + row.offsetHeight / 2;\n  }, [active?.id]);\n\n  const announcement =\n    status === \"paused\"\n      ? typeof statusText === \"string\"\n        ? statusText\n        : \"Paused for you\"\n      : status === \"done\"\n        ? \"Session ended\"\n        : status === \"failed\"\n          ? \"Session failed\"\n          : typeof active?.label === \"string\"\n            ? active.label\n            : \"\";\n\n  const side = layout === \"side\";\n  const auto = layout === \"auto\";\n\n  return (\n    <div\n      data-slot=\"browser-session\"\n      data-status={status}\n      data-layout={layout}\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      {/* Status line */}\n      <div\n        data-slot=\"browser-session-header\"\n        className=\"flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b px-3.5\"\n      >\n        <StatusGlyph status={STATUS_GLYPH[status]} size=\"sm\" label={null} />\n        <span className=\"flex min-w-0 items-baseline gap-2 text-[13px]\">\n          <span\n            className={cn(\n              \"min-w-0 truncate font-medium @md:shrink-0\",\n              status === \"paused\" && \"text-kura-warning-text\",\n            )}\n          >\n            {status === \"browsing\" ? (\n              <ShimmerText duration={2.4}>{STATUS_TEXT.browsing}</ShimmerText>\n            ) : (\n              STATUS_TEXT[status]\n            )}\n          </span>\n          {statusText != null && (\n            <span className=\"hidden min-w-0 truncate text-kura-fg-subtle @md:inline\">\n              {statusText}\n            </span>\n          )}\n        </span>\n        <span className=\"flex-1\" />\n        {steps && steps.length > 0 && (\n          <span className=\"hidden whitespace-nowrap font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums @md:inline\">\n            {done}/{steps.length}\n          </span>\n        )}\n        {elapsed != null && (\n          <span\n            data-slot=\"browser-session-elapsed\"\n            className=\"min-w-[4.5ch] text-right font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n          >\n            {formatClock(elapsed)}\n          </span>\n        )}\n        {actions}\n        {status === \"browsing\" && onTakeOver && (\n          <Button variant=\"outline\" size=\"xs\" onClick={onTakeOver} className=\"-mr-1\">\n            <Hand strokeWidth={1.75} /> Take over\n          </Button>\n        )}\n        {status === \"paused\" && onResume && (\n          <Button variant=\"primary\" size=\"xs\" onClick={onResume} className=\"-mr-1\">\n            <Play strokeWidth={1.75} /> Resume agent\n          </Button>\n        )}\n      </div>\n\n      <div\n        className={cn(\n          \"grid min-w-0\",\n          side && \"grid-cols-[minmax(0,1fr)_minmax(220px,34%)]\",\n          auto && steps && \"@3xl:grid-cols-[minmax(0,1fr)_minmax(240px,32%)]\",\n        )}\n      >\n        {/* Browser */}\n        <div data-slot=\"browser-session-browser\" className=\"flex min-w-0 flex-col\">\n          <div\n            data-slot=\"browser-session-chrome\"\n            className=\"relative flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b px-2.5\"\n          >\n            {title && (\n              <span className=\"hidden max-w-[34%] min-w-0 items-center gap-2 pl-1 @lg:flex\">\n                <span\n                  aria-hidden\n                  className=\"grid size-4 shrink-0 place-items-center rounded-[4px] bg-kura-fg font-semibold text-[9px] text-kura-fg-inverted\"\n                >\n                  {host\n                    .replace(/^www\\./, \"\")\n                    .charAt(0)\n                    .toUpperCase()}\n                </span>\n                <span className=\"truncate font-medium text-[12.5px]\">{title}</span>\n              </span>\n            )}\n            {/* biome-ignore lint/a11y/useSemanticElements: a fieldset would add form semantics to a read-only address */}\n            <div\n              data-slot=\"browser-session-address\"\n              role=\"group\"\n              aria-label=\"Address\"\n              className=\"flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-kura-md bg-kura-surface-sunken px-2.5 shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\"\n            >\n              {secure && (\n                <Lock\n                  aria-label=\"Secure connection\"\n                  strokeWidth={2}\n                  className=\"size-3 shrink-0 text-kura-fg-subtle\"\n                />\n              )}\n              <span className=\"min-w-0 truncate font-kura-mono text-[12px]\">\n                <span className=\"text-kura-fg\">{host}</span>\n                <span className=\"text-kura-fg-subtle\">{rest}</span>\n              </span>\n            </div>\n            {isLoading && (\n              <span\n                data-slot=\"browser-session-loading\"\n                aria-hidden\n                className=\"absolute inset-x-0 -bottom-px h-[2px] animate-kura-shimmer bg-[length:250%_100%] bg-[linear-gradient(90deg,transparent_30%,var(--kura-accent)_50%,transparent_70%)] motion-reduce:animate-none motion-reduce:bg-kura-accent/40\"\n              />\n            )}\n          </div>\n\n          {/* Viewport */}\n          <div\n            ref={frameRef}\n            data-slot=\"browser-session-viewport\"\n            className={cn(\n              \"relative w-full overflow-hidden bg-kura-canvas\",\n              status === \"paused\" &&\n                \"after:pointer-events-none after:absolute after:inset-0 after:z-40 after:shadow-[inset_0_0_0_2px_var(--kura-warning)]\",\n            )}\n            style={{ aspectRatio: ratio }}\n          >\n            <div\n              data-slot=\"browser-session-page\"\n              className={cn(\n                \"absolute top-0 left-0 transition-transform duration-(--kura-duration-slower) ease-kura-in-out motion-reduce:transition-none\",\n                !viewport && \"inset-x-0\",\n                viewport && scale == null && \"opacity-0\",\n              )}\n              style={\n                viewport\n                  ? {\n                      width: viewport.width,\n                      transformOrigin: \"0 0\",\n                      transform: `scale(${scale ?? 1}) translateY(${-scrollTop}px)`,\n                    }\n                  : { transform: `translateY(${-scrollTop}px)` }\n              }\n            >\n              {src ? (\n                <img\n                  src={src}\n                  alt={alt ?? `Screenshot of ${url}`}\n                  className=\"block h-auto w-full\"\n                  draggable={false}\n                />\n              ) : (\n                children\n              )}\n            </div>\n\n            <div\n              data-slot=\"browser-session-overlay\"\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0 z-20\"\n            >\n              {highlight && (\n                <span\n                  data-slot=\"browser-session-highlight\"\n                  className=\"absolute rounded-[5px] bg-kura-accent/[0.06] shadow-[0_0_0_1.5px_var(--kura-accent)] transition-[left,top,width,height,opacity] duration-(--kura-duration-slow) ease-kura-out starting:opacity-0 motion-reduce:transition-opacity\"\n                  style={{\n                    left: `${highlight.x}%`,\n                    top: `${highlight.y}%`,\n                    width: `${highlight.width}%`,\n                    height: `${highlight.height}%`,\n                  }}\n                >\n                  {highlight.label && (\n                    <span className=\"absolute bottom-full left-[-1.5px] mb-1 max-w-[240px] truncate whitespace-nowrap rounded-[4px] bg-kura-accent px-1.5 font-kura-mono text-[10.5px] text-kura-accent-fg leading-4\">\n                      {highlight.label}\n                    </span>\n                  )}\n                </span>\n              )}\n              {cursor && ripple != null && <Ripple key={String(ripple)} point={cursor} />}\n              {cursor && <Cursor point={cursor} label={cursorLabel} />}\n            </div>\n          </div>\n        </div>\n\n        {/* Step log */}\n        {steps && (\n          <div\n            data-slot=\"browser-session-log\"\n            className={cn(\n              \"relative min-w-0 border-kura-border-subtle\",\n              side ? \"border-l\" : \"border-t\",\n              auto && \"@3xl:border-t-0 @3xl:border-l\",\n            )}\n          >\n            <div\n              className={cn(\n                \"overflow-y-auto overscroll-contain\",\n                side && \"absolute inset-0\",\n                !side && \"max-h-56\",\n                auto && \"@3xl:absolute @3xl:inset-0 @3xl:max-h-none\",\n              )}\n            >\n              <ol ref={logRef} aria-label=\"Browser actions\" className=\"flex flex-col\">\n                {steps.map((step) => (\n                  <StepRow key={step.id} step={step} active={step.id === active?.id} />\n                ))}\n              </ol>\n            </div>\n          </div>\n        )}\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/kura/browser-session/index.ts",
      "type": "registry:ui",
      "target": "components/kura/browser-session/index.ts",
      "content": "export {\n  type BrowserHighlight,\n  type BrowserPoint,\n  BrowserSession,\n  type BrowserSessionProps,\n  type BrowserStep,\n  type BrowserStepKind,\n} from \"./browser-session\";\n"
    }
  ],
  "categories": [
    "workspace",
    "agent",
    "browser",
    "computer-use",
    "browser-use"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/browser-session · Markdown for agents: https://kuro.abstergo.fyi/components/browser-session.md",
  "meta": {
    "kura": {
      "status": "experimental",
      "whenToUse": [
        "Show what a browser-use or computer-use agent is doing on a web page (Operator, Claude computer use, Browserbase, Playwright-driven agents).",
        "Stream screenshots of a remote browser (`src`) with the agent's next action drawn on top (`cursor`, `highlight`).",
        "Replay a recorded browser run step by step with the log of navigate / click / type / scroll / extract actions.",
        "Hand control to the human when the agent hits a login, 2FA or CAPTCHA (`status=\"paused\"` + `onResume`), or let them take over (`onTakeOver`)."
      ],
      "whenNotToUse": [
        "Terminal or shell output from the agent — use terminal.",
        "Code edits made by the agent — use diff-view or file-changes.",
        "A plan or todo list of the whole run — use task-list.",
        "A static rendered artifact (HTML preview, document) — use artifact-card."
      ]
    }
  }
}
