{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"shared-whiteboard","type":"registry:ui","title":"SharedWhiteboard","description":"Whiteboard + bridges + share.","package":"agent-integrations","dependencies":["@particle-academy\/fancy-whiteboard","],\r\n  notes: StickyNoteItem[],\r\n  shapes: ShapeItem[],\r\n): { x: number; y: number } | null {\r\n  if (typeof ref === "],"registryDependencies":["mcp","sharing","bridges","share-controls","agent-panel","agent-cursor","agent-activity-highlight"],"files":[{"path":"components\/fancy\/shared-whiteboard\/SharedWhiteboard.tsx","content":"import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\r\nimport {\r\n  Board,\r\n  StickyNote,\r\n  Connector,\r\n  Shape,\r\n  CursorLayer,\r\n  type StickyNoteItem,\r\n  type ShapeItem,\r\n  type ConnectorItem,\r\n  type Stroke,\r\n  type RemoteCursor,\r\n  type Viewport,\r\n} from \"@particle-academy\/fancy-whiteboard\";\r\nimport { MicroMcpServer, type Transport } from \"..\/..\/mcp\/server\";\r\nimport { attachInProcess, type InProcessTransport } from \"..\/..\/mcp\/transports\/in-process\";\r\nimport { attachSseRelay, type RelayState, type SseRelayTransport } from \"..\/..\/sharing\/sse-relay\";\r\nimport { createSessionDescriptor, type SessionDescriptor } from \"..\/..\/sharing\/token\";\r\nimport { registerWhiteboardBridge } from \"..\/..\/bridges\/whiteboard\";\r\nimport type { Bridge } from \"..\/..\/bridges\/types\";\r\nimport { ShareControls } from \"..\/ShareControls\";\r\nimport { AgentPanel, type AgentActivity } from \"..\/AgentPanel\";\r\nimport { AgentCursor } from \"..\/AgentCursor\";\r\nimport { AgentActivityHighlight } from \"..\/AgentActivityHighlight\";\r\n\r\nexport type SharedWhiteboardProps = {\r\n  \/** Initial board contents. *\/\r\n  initialNotes?: StickyNoteItem[];\r\n  initialShapes?: ShapeItem[];\r\n  initialConnectors?: ConnectorItem[];\r\n  initialStrokes?: Stroke[];\r\n  initialViewport?: Viewport;\r\n\r\n  \/** Agent identity displayed in the panel + cursor. *\/\r\n  agent?: { id: string; name?: string; color?: string };\r\n\r\n  \/**\r\n   * Where the relay HTTP endpoints live. The host app implements these (see\r\n   * docs\/relay-protocol.md). Pass `null` to disable sharing \u2014 the board\r\n   * still works locally with the in-process MCP server.\r\n   *\/\r\n  shareBaseUrl?: string | null;\r\n\r\n  \/**\r\n   * Optional callback to register a new session token with the host's\r\n   * relay broker. Receives `{ session, token }` and should return after\r\n   * registration. Defaults to POSTing JSON to `${shareBaseUrl}\/register`.\r\n   *\/\r\n  onRegisterSession?: (descriptor: SessionDescriptor) => Promise<void>;\r\n\r\n  \/** Show the agent panel. Default true. *\/\r\n  showAgentPanel?: boolean;\r\n\r\n  \/** Show share controls. Default true. *\/\r\n  showShareControls?: boolean;\r\n\r\n  \/** Auto-broadcast local edits as `notifications\/state_update`. Default true. *\/\r\n  broadcastEdits?: boolean;\r\n\r\n  \/** Pixel height of the board area. Default 640. *\/\r\n  height?: number;\r\n\r\n  \/** Header content rendered above the board. *\/\r\n  header?: ReactNode;\r\n\r\n  className?: string;\r\n  style?: CSSProperties;\r\n};\r\n\r\nconst DEFAULT_AGENT = { id: \"agent\", name: \"Agent\", color: \"#a855f7\" };\r\n\r\n\/**\r\n * SharedWhiteboard \u2014 drop-in component that bundles every piece of the\r\n * \"agent-collaborative whiteboard\" UX: board with all primitives, in-page\r\n * MCP server, share controls, agent panel, presence cursor, activity\r\n * highlight, and outbound state broadcast.\r\n *\r\n * Most apps only need this one component. For deeper customization, swap\r\n * it for the lower-level primitives (Board, MicroMcpServer, ShareControls).\r\n *\/\r\nexport function SharedWhiteboard({\r\n  initialNotes = [],\r\n  initialShapes = [],\r\n  initialConnectors = [],\r\n  initialStrokes = [],\r\n  initialViewport = { x: 0, y: 0, zoom: 1 },\r\n  agent = DEFAULT_AGENT,\r\n  shareBaseUrl = \"\/agent-relay\",\r\n  onRegisterSession,\r\n  showAgentPanel = true,\r\n  showShareControls = true,\r\n  broadcastEdits = true,\r\n  height = 640,\r\n  header,\r\n  className,\r\n  style,\r\n}: SharedWhiteboardProps) {\r\n  \/\/ Board state\r\n  const [notes, setNotes] = useState<StickyNoteItem[]>(initialNotes);\r\n  const [shapes, setShapes] = useState<ShapeItem[]>(initialShapes);\r\n  const [connectors, setConnectors] = useState<ConnectorItem[]>(initialConnectors);\r\n  const [strokes, setStrokes] = useState<Stroke[]>(initialStrokes);\r\n  const [viewport, setViewport] = useState<Viewport>(initialViewport);\r\n  const [agentCursor, setAgentCursor] = useState<RemoteCursor | null>(null);\r\n\r\n  \/\/ Agent UX state\r\n  const [activity, setActivity] = useState<AgentActivity[]>([]);\r\n  const [highlight, setHighlight] = useState<{ pulseKey: number; bounds: { x: number; y: number; width: number; height: number } } | null>(null);\r\n\r\n  const stateRefs = useRef({ notes, shapes, connectors, strokes, viewport });\r\n  useEffect(() => { stateRefs.current = { notes, shapes, connectors, strokes, viewport }; }, [notes, shapes, connectors, strokes, viewport]);\r\n\r\n  \/\/ MCP server + bridge\r\n  const serverRef = useRef<MicroMcpServer | null>(null);\r\n  const inProcRef = useRef<InProcessTransport | null>(null);\r\n  const bridgeRef = useRef<Bridge | null>(null);\r\n\r\n  useEffect(() => {\r\n    const server = new MicroMcpServer({\r\n      info: { name: \"shared-whiteboard\", version: \"0.2.0\" },\r\n      instructions: \"Collaborative whiteboard. Use whiteboard_* tools to read or modify the board.\",\r\n    });\r\n    bridgeRef.current = registerWhiteboardBridge(server, {\r\n      adapter: {\r\n        getNotes: () => stateRefs.current.notes,\r\n        setNotes: (next) => setNotes(typeof next === \"function\" ? next : () => next),\r\n        getShapes: () => stateRefs.current.shapes,\r\n        setShapes: (next) => setShapes(typeof next === \"function\" ? next : () => next),\r\n        getConnectors: () => stateRefs.current.connectors,\r\n        setConnectors: (next) => setConnectors(typeof next === \"function\" ? next : () => next),\r\n        getStrokes: () => stateRefs.current.strokes,\r\n        setStrokes: (next) => setStrokes(typeof next === \"function\" ? next : () => next),\r\n        getViewport: () => stateRefs.current.viewport,\r\n        setViewport,\r\n        setAgentCursor,\r\n      },\r\n      agent,\r\n    });\r\n    inProcRef.current = attachInProcess(server);\r\n    serverRef.current = server;\r\n\r\n    \/\/ Pulse a highlight whenever a tool call returns a structured id.\r\n    const off = inProcRef.current.onServerMessage((msg: any) => {\r\n      if (msg?.id !== undefined && \"result\" in msg && msg.result?.structuredContent?.id) {\r\n        const id = msg.result.structuredContent.id;\r\n        requestAnimationFrame(() => pulseFor(id));\r\n      }\r\n    });\r\n    return () => {\r\n      off();\r\n      bridgeRef.current?.dispose();\r\n      bridgeRef.current = null;\r\n      if (inProcRef.current) server.detach(inProcRef.current);\r\n    };\r\n    \/\/ eslint-disable-next-line react-hooks\/exhaustive-deps\r\n  }, []);\r\n\r\n  const pulseFor = (id: string) => {\r\n    const n = stateRefs.current.notes.find((x) => x.id === id);\r\n    if (n) return setHighlight({ pulseKey: Date.now(), bounds: { x: n.x, y: n.y, width: n.width, height: n.height } });\r\n    const s = stateRefs.current.shapes.find((x) => x.id === id);\r\n    if (s) return setHighlight({ pulseKey: Date.now(), bounds: { x: s.x, y: s.y, width: s.width, height: s.height } });\r\n  };\r\n\r\n  const log = useCallback((entry: Omit<AgentActivity, \"id\" | \"at\">) => {\r\n    setActivity((all) => [...all.slice(-200), { id: `a_${Date.now()}_${all.length}`, at: Date.now(), ...entry }]);\r\n  }, []);\r\n\r\n  \/\/ Sharing\r\n  const [session, setSession] = useState<SessionDescriptor | null>(null);\r\n  const [relayState, setRelayState] = useState<RelayState>(\"idle\");\r\n  const sseRef = useRef<SseRelayTransport | null>(null);\r\n  const logEsRef = useRef<EventSource | null>(null);\r\n\r\n  const startShare = async () => {\r\n    if (session || !serverRef.current || !shareBaseUrl) return;\r\n    const desc = createSessionDescriptor();\r\n\r\n    try {\r\n      if (onRegisterSession) {\r\n        await onRegisterSession(desc);\r\n      } else {\r\n        const csrf = (document.querySelector('meta[name=\"csrf-token\"]') as HTMLMetaElement | null)?.content ?? \"\";\r\n        const reg = await fetch(`${shareBaseUrl}\/register`, {\r\n          method: \"POST\",\r\n          headers: { \"content-type\": \"application\/json\", \"x-csrf-token\": csrf, accept: \"application\/json\" },\r\n          body: JSON.stringify({ session: desc.id, token: desc.token }),\r\n        });\r\n        if (!reg.ok) throw new Error(`registration failed (HTTP ${reg.status})`);\r\n      }\r\n    } catch (e) {\r\n      log({ kind: \"error\", source: \"share\", text: e instanceof Error ? e.message : String(e) });\r\n      return;\r\n    }\r\n\r\n    const relay = attachSseRelay(serverRef.current, {\r\n      baseUrl: shareBaseUrl,\r\n      sessionId: desc.id,\r\n      token: desc.token,\r\n    });\r\n    sseRef.current = relay;\r\n    relay.onStateChange(setRelayState);\r\n\r\n    \/\/ Activity log mirror \u2014 does NOT call deliverFromRemote (relay does it).\r\n    const es = new EventSource(`${shareBaseUrl}\/${desc.id}\/events?token=${desc.token}&direction=inbound`);\r\n    es.addEventListener(\"mcp\", (ev: MessageEvent) => {\r\n      try {\r\n        const frame = JSON.parse(ev.data);\r\n        if (frame.method === \"notifications\/peer_joined\") {\r\n          \/\/ External agent just opened an outbound subscription. Surface it\r\n          \/\/ so the human can see \"an agent is connecting\" before any tools\r\n          \/\/ arrive. The agent itself should follow up with set_agent_cursor\r\n          \/\/ immediately, but in case it doesn't, drop a placeholder cursor\r\n          \/\/ at the canvas origin so presence is visible right away.\r\n          setAgentCursor((c) => c ?? { userId: agent.id, name: agent.name, color: agent.color, x: 60, y: 60 });\r\n          log({ kind: \"info\", source: \"presence\", text: `${agent.name ?? \"Agent\"} connected` });\r\n          return;\r\n        }\r\n        if (frame.method === \"notifications\/peer_left\") {\r\n          setAgentCursor(null);\r\n          log({ kind: \"info\", source: \"presence\", text: `${agent.name ?? \"Agent\"} disconnected` });\r\n          return;\r\n        }\r\n        if (frame.method === \"notifications\/agent_message\") {\r\n          log({ kind: \"message\", source: agent.name ?? \"Agent\", text: String(frame.params?.text ?? \"\") });\r\n        } else if (frame.method === \"notifications\/agent_status\") {\r\n          log({ kind: \"info\", source: agent.name ?? \"Agent\", text: String(frame.params?.text ?? \"\") });\r\n        } else if (frame.method?.startsWith(\"notifications\/\")) {\r\n          \/\/ ignore other notifications in the feed\r\n        } else {\r\n          log({ kind: \"tool\", source: \"remote\", text: `\u2190 ${frame.method ?? `id:${frame.id}`}`, detail: frame });\r\n        }\r\n      } catch {\r\n        \/* noop *\/\r\n      }\r\n    });\r\n    logEsRef.current = es;\r\n\r\n    setSession(desc);\r\n    log({ kind: \"info\", source: \"share\", text: `Sharing started \u00b7 session ${desc.id}` });\r\n  };\r\n\r\n  const stopShare = async () => {\r\n    if (!session) return;\r\n    const desc = session;\r\n    setSession(null);\r\n    logEsRef.current?.close();\r\n    logEsRef.current = null;\r\n    if (sseRef.current && serverRef.current) serverRef.current.detach(sseRef.current);\r\n    sseRef.current = null;\r\n    setRelayState(\"closed\");\r\n    if (shareBaseUrl) {\r\n      const csrf = (document.querySelector('meta[name=\"csrf-token\"]') as HTMLMetaElement | null)?.content ?? \"\";\r\n      await fetch(`${shareBaseUrl}\/${desc.id}\/unregister?token=${encodeURIComponent(desc.token)}`, {\r\n        method: \"POST\",\r\n        headers: { \"x-csrf-token\": csrf, accept: \"application\/json\" },\r\n      }).catch(() => {});\r\n    }\r\n    log({ kind: \"info\", source: \"share\", text: \"Sharing stopped.\" });\r\n  };\r\n\r\n  \/\/ Outbound state broadcast \u2014 every state change pushes a notification on\r\n  \/\/ the relay (capped to ~12 Hz) so external agents see human edits live.\r\n  const lastBroadcastRef = useRef(0);\r\n  useEffect(() => {\r\n    if (!broadcastEdits || !sseRef.current || !session) return;\r\n    const now = Date.now();\r\n    if (now - lastBroadcastRef.current < 80) return;\r\n    lastBroadcastRef.current = now;\r\n    sseRef.current.send({\r\n      jsonrpc: \"2.0\",\r\n      method: \"notifications\/state_update\",\r\n      params: { notes, shapes, connectors, viewport, ts: now },\r\n    });\r\n  }, [notes, shapes, connectors, viewport, session, broadcastEdits]);\r\n\r\n  const handleSubmit = (text: string) => {\r\n    if (!sseRef.current) {\r\n      log({ kind: \"error\", source: \"you\", text: \"Start a shared session first.\" });\r\n      return;\r\n    }\r\n    sseRef.current.send({\r\n      jsonrpc: \"2.0\",\r\n      method: \"notifications\/user_message\",\r\n      params: { text, ts: Date.now() },\r\n    });\r\n    log({ kind: \"message\", source: \"You\", text });\r\n  };\r\n\r\n  \/\/ The agent cursor is rendered by <AgentCursor> below \u2014 keep it OUT of\r\n  \/\/ the cursors array so we don't double-render it via CursorLayer.\r\n  \/\/ (CursorLayer is reserved for human participants once relay sync lands.)\r\n  const cursors: RemoteCursor[] = useMemo(() => [], []);\r\n  const statusText = (() => {\r\n    switch (relayState) {\r\n      case \"open\": return \"live\";\r\n      case \"connecting\": return \"connecting\u2026\";\r\n      case \"error\": return \"error\";\r\n      case \"closed\": return \"closed\";\r\n      default: return undefined;\r\n    }\r\n  })();\r\n\r\n  return (\r\n    <div className={[\"fai-shared-whiteboard\", className ?? \"\"].filter(Boolean).join(\" \")} style={style}>\r\n      {header}\r\n      {showShareControls && shareBaseUrl !== null && (\r\n        <div className=\"fai-shared-whiteboard__controls\">\r\n          <ShareControls session={session} onStart={startShare} onStop={stopShare} status={statusText} \/>\r\n        <\/div>\r\n      )}\r\n      <div\r\n        className=\"fai-shared-whiteboard__layout\"\r\n        style={{\r\n          display: \"grid\",\r\n          gap: 16,\r\n          gridTemplateColumns: showAgentPanel ? \"1fr 360px\" : \"1fr\",\r\n        }}\r\n      >\r\n        <div\r\n          className=\"fai-shared-whiteboard__board\"\r\n          style={{\r\n            position: \"relative\",\r\n            overflow: \"hidden\",\r\n            borderRadius: 12,\r\n            border: \"1px solid #e4e4e7\",\r\n            background:\r\n              \"radial-gradient(circle at 1px 1px, rgba(0,0,0,0.07) 1px, transparent 0)\",\r\n            backgroundSize: \"20px 20px\",\r\n            height,\r\n          }}\r\n        >\r\n          <Board viewport={viewport} onViewportChange={setViewport} style={{ width: \"100%\", height: \"100%\" }}>\r\n            {connectors.map((c) => {\r\n              const a = resolveCenter(c.from, notes, shapes);\r\n              const b = resolveCenter(c.to, notes, shapes);\r\n              if (!a || !b) return null;\r\n              return <Connector key={c.id} from={a} to={b} color={c.color ?? \"#64748b\"} \/>;\r\n            })}\r\n            {shapes.map((s) => (\r\n              <Shape key={s.id} item={s} onChange={(next) => setShapes((all) => all.map((x) => (x.id === next.id ? next : x)))} \/>\r\n            ))}\r\n            {notes.map((n) => (\r\n              <StickyNote key={n.id} item={n} onChange={(next) => setNotes((all) => all.map((x) => (x.id === next.id ? next : x)))} \/>\r\n            ))}\r\n            <CursorLayer cursors={cursors} \/>\r\n            {agentCursor && (\r\n              <AgentCursor x={agentCursor.x} y={agentCursor.y} name={agentCursor.name} color={agentCursor.color} \/>\r\n            )}\r\n            {highlight && (\r\n              <AgentActivityHighlight\r\n                x={highlight.bounds.x}\r\n                y={highlight.bounds.y}\r\n                width={highlight.bounds.width}\r\n                height={highlight.bounds.height}\r\n                color={agent.color ?? \"#a855f7\"}\r\n                pulseKey={highlight.pulseKey}\r\n              \/>\r\n            )}\r\n          <\/Board>\r\n        <\/div>\r\n        {showAgentPanel && (\r\n          <div style={{ height }}>\r\n            <AgentPanel\r\n              agent={agent}\r\n              activity={activity}\r\n              onSubmit={handleSubmit}\r\n            \/>\r\n          <\/div>\r\n        )}\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n\r\nfunction resolveCenter(\r\n  ref: ConnectorItem[\"from\"],\r\n  notes: StickyNoteItem[],\r\n  shapes: ShapeItem[],\r\n): { x: number; y: number } | null {\r\n  if (typeof ref === \"string\") {\r\n    const n = notes.find((x) => x.id === ref);\r\n    if (n) return { x: n.x + n.width \/ 2, y: n.y + n.height \/ 2 };\r\n    const s = shapes.find((x) => x.id === ref);\r\n    if (s) return { x: s.x + s.width \/ 2, y: s.y + s.height \/ 2 };\r\n    return null;\r\n  }\r\n  return ref;\r\n}\r\n\r\n\/\/ Internal-import safety\r\ntype _UseTransport = Transport;\r\n","type":"registry:ui","target":"components\/fancy\/shared-whiteboard\/SharedWhiteboard.tsx"},{"path":"components\/fancy\/shared-whiteboard\/index.ts","content":"export { SharedWhiteboard } from \".\/SharedWhiteboard\";\r\nexport type { SharedWhiteboardProps } from \".\/SharedWhiteboard\";\r\n","type":"registry:ui","target":"components\/fancy\/shared-whiteboard\/index.ts"}]}