{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-screen","title":"Agent Screen","description":"Watch an agent work, teach a task, or record a run.","dependencies":[],"registryDependencies":["https://ward.so/r/foundation.json","https://ward.so/r/button.json"],"files":[{"path":"registry/ward/AgentScreen.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/foundation.css\";\n\nimport { useEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Button } from \"@/registry/ward/ui/button\";\n\n/* ─────────────────────────────────────────────────────────\n * AGENT SCREEN (live viewer)\n * Watch an agent work. The resting card is a framed capture of\n * the agent's screen; hover reveals an \"Open\" pill (the blue\n * accent Button). Open expands to a full-width viewer where you\n * can \"Teach a task\" — which starts recording — collapse\n * (recording keeps running, the card shows a red REC badge), and\n * end it.\n *\n * The screen is a placeholder image by default; pass a `streamSrc`\n * (image or video URL) to pipe in a real stream.\n * ───────────────────────────────────────────────────────── */\n\n/* aspect ratio of the placeholder capture (2964×1856) — used so the collapsed\n * card shows the whole desktop with no crop */\nconst SCREEN_ASPECT = \"aspect-[2964/1856]\";\n\nfunction Ico({ path, size = 15, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={sw} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      {path}\n    </svg>\n  );\n}\n\n/* minimize-2 — two arrows converging to the middle (collapse) */\nconst collapseIcon = (\n  <>\n    <polyline points=\"4 14 10 14 10 20\" />\n    <polyline points=\"20 10 14 10 14 4\" />\n    <line x1=\"14\" y1=\"10\" x2=\"21\" y2=\"3\" />\n    <line x1=\"3\" y1=\"21\" x2=\"10\" y2=\"14\" />\n  </>\n);\n\n/* maximize-2 — two arrows out to opposite corners (open) */\nconst openIcon = (\n  <>\n    <polyline points=\"15 3 21 3 21 9\" />\n    <polyline points=\"9 21 3 21 3 15\" />\n    <line x1=\"21\" y1=\"3\" x2=\"14\" y2=\"10\" />\n    <line x1=\"3\" y1=\"21\" x2=\"10\" y2=\"14\" />\n  </>\n);\n\nfunction fmt(total: number) {\n  const m = Math.floor(total / 60);\n  const s = total % 60;\n  return `${String(m).padStart(2, \"0\")}:${String(s).padStart(2, \"0\")}`;\n}\n\n/* macOS-style pointer */\nfunction CursorSvg({ className, style }: { className?: string; style?: React.CSSProperties }) {\n  return (\n    <svg className={className} style={style} width=\"30\" height=\"30\" viewBox=\"0 0 24 24\" fill=\"#111318\" stroke=\"#fff\" strokeWidth=\"1.4\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z\" />\n    </svg>\n  );\n}\n\n/* collapsed card: a static decorative cursor sitting on the capture */\nfunction DriftCursor() {\n  return <CursorSvg className=\"agent-cursor\" style={{ left: \"42%\", top: \"53%\" }} />;\n}\n\n/* the agent's screen — a real stream via streamSrc, else a faux window.\n * Media is absolutely positioned so it always fills its (relative) parent —\n * an h-full chain through an aspect-ratio box can collapse and leak the bg. */\nfunction Screen({ streamSrc, cursor = true }: { streamSrc?: string; cursor?: boolean }) {\n  return (\n    <div className=\"absolute inset-0 overflow-hidden bg-inset\">\n      {streamSrc ? (\n        /\\.(mp4|webm|mov|m4v)(\\?|$)/i.test(streamSrc) ? (\n          <video src={streamSrc} controls playsInline preload=\"none\" className=\"absolute inset-0 h-full w-full object-cover\" />\n        ) : (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img src={streamSrc} alt=\"\" className=\"absolute inset-0 h-full w-full object-cover\" />\n        )\n      ) : (\n        <FauxWindow />\n      )}\n      {cursor && <DriftCursor />}\n    </div>\n  );\n}\n\n/* expanded viewer media — the image sizes itself within the viewport (bounded\n * by width and height) so the whole screen always fits with no crop */\nfunction MediaSizer({ src }: { src: string }) {\n  const style = { maxHeight: \"calc(100vh - 150px)\", maxWidth: \"min(960px, 90vw)\" } as const;\n  const cls = \"block h-auto w-auto object-contain\";\n  return /\\.(mp4|webm|mov|m4v)(\\?|$)/i.test(src) ? (\n    <video src={src} controls playsInline preload=\"none\" className={cls} style={style} />\n  ) : (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img src={src} alt=\"\" className={cls} style={style} />\n  );\n}\n\n/* connecting state — spinner on black, same ring as Task Rows */\nfunction LoadingScreen() {\n  const size = 26,\n    stroke = 2,\n    r = (size - stroke) / 2,\n    c = 2 * Math.PI * r;\n  return (\n    <div className=\"absolute inset-0 bg-black\">\n      {/* spinner dead-center; wrapper carries the centering so the spin\n          transform on the svg doesn't override it */}\n      <span className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">\n        <svg width={size} height={size} className=\"block\" style={{ animation: \"spin 1.1s linear infinite\" }} aria-hidden>\n          <circle cx={size / 2} cy={size / 2} r={r} fill=\"none\" stroke=\"rgba(255,255,255,0.18)\" strokeWidth={stroke} />\n          <circle cx={size / 2} cy={size / 2} r={r} fill=\"none\" stroke=\"#fff\" strokeWidth={stroke} strokeLinecap=\"round\" strokeDasharray={`${c * 0.28} ${c * 0.72}`} />\n        </svg>\n      </span>\n      <span\n        className=\"absolute inset-x-0 text-center text-[12.5px] font-medium text-white/70\"\n        style={{ top: \"calc(50% + 28px)\" }}\n      >\n        Connecting to agent&apos;s screen\n      </span>\n    </div>\n  );\n}\n\nfunction FauxWindow() {\n  return (\n    <div className=\"flex h-full w-full flex-col bg-card\">\n      <div className=\"flex shrink-0 items-center gap-1.5 border-b border-line bg-inset px-2.5 py-1.5\">\n        <span className=\"flex items-center gap-1\">\n          <span className=\"size-2 rounded-full bg-red\" />\n          <span className=\"size-2 rounded-full bg-orange\" />\n          <span className=\"size-2 rounded-full bg-green\" />\n        </span>\n        <span className=\"ml-1 flex min-w-0 items-center gap-1.5 rounded-t-[6px] bg-card px-2 py-1 shadow-[0_-1px_0_var(--line)]\">\n          <span className=\"size-2 shrink-0 rounded-[3px] bg-highlight-tint\" />\n          <span className=\"h-1.5 w-14 rounded-full bg-line-strong\" />\n        </span>\n      </div>\n      <div className=\"flex shrink-0 items-center gap-2 border-b border-line px-2.5 py-1.5 text-ink-3\">\n        <Ico size={12} path={<path d=\"M15 18l-6-6 6-6\" />} />\n        <Ico size={12} path={<path d=\"M9 6l6 6-6 6\" />} />\n        <span className=\"min-w-0 flex-1 truncate rounded-full bg-field px-2.5 py-[3px] font-mono text-[9px] text-ink-3\">\n          workspace.example/projects/brief\n        </span>\n      </div>\n      <div className=\"flex flex-1 flex-col gap-2.5 overflow-hidden p-3\">\n        <div className=\"flex items-center gap-2\">\n          <span className=\"grid size-4 place-items-center rounded-[4px] bg-highlight-tint text-[8px] font-bold text-highlight\">W</span>\n          <span className=\"h-1.5 w-12 rounded-full bg-line-strong\" />\n          <span className=\"ml-auto h-4 w-12 rounded-full bg-inset shadow-btn\" />\n        </div>\n        <div className=\"flex items-center gap-2 rounded-[8px] bg-inset p-2\">\n          <span className=\"h-3 flex-1 rounded-full bg-card shadow-hairline\" />\n          <span className=\"h-3 w-9 rounded-full bg-highlight\" />\n        </div>\n        {[\"w-2/5\", \"w-1/2\", \"w-1/3\", \"w-2/5\"].map((w, i) => (\n          <div key={i} className=\"flex items-center gap-2.5\">\n            <span className=\"size-6 shrink-0 rounded-full bg-highlight-tint\" />\n            <span className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n              <span className={`h-1.5 rounded-full bg-line-strong ${w}`} />\n              <span className=\"h-1.5 w-3/5 rounded-full bg-line\" />\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default function AgentScreen({\n  agentName = \"Agent\",\n  streamSrc,\n  variant,\n}: {\n  agentName?: string;\n  streamSrc?: string;\n  variant?: string;\n} = {}) {\n  const loading = variant === \"Loading\";\n  const [open, setOpen] = useState(false);\n  const [recording, setRecording] = useState(false);\n  const [secs, setSecs] = useState(0);\n  const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null);\n\n\n  // tick while recording — survives collapse (state lives here, not the overlay)\n  useEffect(() => {\n    if (!recording) return;\n    const id = setInterval(() => setSecs((s) => s + 1), 1000);\n    return () => clearInterval(id);\n  }, [recording]);\n\n  // lock scroll + Esc-to-collapse while the viewer is open\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    document.addEventListener(\"keydown\", onKey);\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.body.style.overflow = \"\";\n    };\n  }, [open]);\n\n  const startRecording = () => {\n    setSecs(0);\n    setRecording(true);\n  };\n  const endRecording = () => {\n    setRecording(false);\n    setSecs(0);\n  };\n\n  const controls = (\n    <div className=\"flex shrink-0 items-center gap-1.5\">\n      {recording ? (\n        <button\n          type=\"button\"\n          onClick={endRecording}\n          className=\"inline-flex h-[27px] items-center gap-1.5 rounded-full bg-red pl-2.5 pr-3 text-[13px] font-medium leading-none text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.14)] transition-[transform,filter] duration-150 ease-out hover:brightness-95 active:scale-[0.96]\"\n        >\n          <span className=\"size-2.5 rounded-[2px] bg-white\" />\n          End\n        </button>\n      ) : (\n        <Button variant=\"secondary\" size=\"sm\" className=\"h-7 gap-1 pl-1.5 text-[13px]\" onClick={startRecording}>\n          <Ico size={15} path={<><circle cx=\"12\" cy=\"12\" r=\"9\" /><circle cx=\"12\" cy=\"12\" r=\"3.5\" fill=\"currentColor\" stroke=\"none\" /></>} />\n          Teach a task\n        </Button>\n      )}\n      <button\n        type=\"button\"\n        aria-label=\"Collapse\"\n        onClick={() => setOpen(false)}\n        className=\"primitive-icon-button text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink\"\n      >\n        <Ico size={15} path={collapseIcon} />\n      </button>\n    </div>\n  );\n\n  return (\n    <div className=\"w-full max-w-[340px]\">\n      {/* ── resting card — hover/click scoped to the Mac window only ── */}\n      <div\n        className={`group/screen relative ${SCREEN_ASPECT} overflow-hidden rounded-window bg-inset shadow-card transition-shadow duration-150 ${\n          loading ? \"\" : \"cursor-pointer hover:shadow-raised\"\n        }`}\n        onClick={loading ? undefined : () => setOpen(true)}\n        style={{ animation: \"fade-up 380ms cubic-bezier(0.23,1,0.32,1) both\" }}\n      >\n        {loading ? (\n          <LoadingScreen />\n        ) : (\n          <>\n            <Screen streamSrc={streamSrc} />\n\n            {/* hover reveal — scoped to this frame's named group, not the gallery section */}\n            <div className=\"absolute inset-0 flex items-center justify-center bg-[rgba(17,19,24,0)] transition-colors duration-150 group-hover/screen:bg-[rgba(17,19,24,0.18)]\">\n              <span className=\"translate-y-1 opacity-0 transition duration-150 group-hover/screen:translate-y-0 group-hover/screen:opacity-100\">\n                <Button\n                  variant=\"default\"\n                  size=\"sm\"\n                  className=\"h-7 text-[13px]\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setOpen(true);\n                  }}\n                >\n                  <Ico size={14} path={openIcon} />\n                  Open\n                </Button>\n              </span>\n            </div>\n          </>\n        )}\n      </div>\n\n      <div className=\"mt-2.5 truncate px-0.5 text-[13px] font-medium text-ink\">{agentName}&apos;s screen</div>\n\n      {/* ── expanded viewer — portaled to <body> so it takes over the whole page ── */}\n      {open &&\n        typeof document !== \"undefined\" &&\n        createPortal(\n          <div\n            className=\"fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6\"\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-label={`${agentName}'s screen`}\n          >\n            <div\n              className=\"absolute inset-0 bg-black/60 dark:bg-black/75\"\n              style={{ animation: \"fade-in 180ms ease-out both\" }}\n              onClick={() => setOpen(false)}\n            />\n            <div\n              className=\"relative flex max-h-full flex-col overflow-hidden rounded-[16px] bg-card p-2 pt-0 shadow-overlay\"\n              style={{ animation: \"pop-in 240ms cubic-bezier(0.23,1,0.32,1) both\" }}\n            >\n              {/* title bar — agent name far left, controls right */}\n              <div className=\"flex h-11 shrink-0 items-center justify-between gap-3 px-1.5\">\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  <span className=\"truncate text-[13px] font-semibold text-ink\">{agentName}</span>\n                  {recording && (\n                    <span className=\"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-red-tint py-0.5 pl-1.5 pr-2 text-[11.5px] font-medium tabular-nums text-red\">\n                      <span className=\"size-2 rounded-full bg-red\" style={{ animation: \"records-pulse 1.1s ease-in-out infinite\" }} />\n                      {fmt(secs)}\n                    </span>\n                  )}\n                </div>\n                {controls}\n              </div>\n\n              {/* the screen — inset with a little padding (the crop framing);\n                  the image sizes the window so the whole screen fits */}\n              <div\n                className=\"relative min-h-0 overflow-hidden rounded-[8px] bg-inset [cursor:none]\"\n                onMouseMove={(e) => {\n                  const r = e.currentTarget.getBoundingClientRect();\n                  setCursorPos({ x: e.clientX - r.left, y: e.clientY - r.top });\n                }}\n                onMouseLeave={() => setCursorPos(null)}\n              >\n                {loading ? (\n                  <div className={SCREEN_ASPECT} style={{ width: \"min(960px, 90vw)\" }}>\n                    <LoadingScreen />\n                  </div>\n                ) : (\n                  streamSrc ? <MediaSizer src={streamSrc} /> : <div className={SCREEN_ASPECT} style={{ width: \"min(960px, 90vw)\" }}><Screen cursor={false} /></div>\n                )}\n                {!loading && cursorPos && (\n                  <CursorSvg\n                    className=\"pointer-events-none absolute z-10\"\n                    style={{ left: cursorPos.x, top: cursorPos.y, filter: \"drop-shadow(0 1px 1.5px rgba(0,0,0,0.35))\" }}\n                  />\n                )}\n              </div>\n            </div>\n          </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n","type":"registry:component","target":"components/ward/AgentScreen.tsx"}],"meta":{"variants":["Working","Loading"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/AgentScreen.tsx","access":"free"},"categories":["ai"],"type":"registry:component"}