/** * @file participants-chrome.tsx — top-right avatar stack of live peers * @scope apps/studio/participants-chrome.tsx * @purpose Phase 8 Task 6 — show who's currently on the canvas. Colored * initials in a circle per peer, with a popover for full name + * "Follow" button. Follow mode publishes `followTarget` on my * Awareness; a separate effect pulls the target's viewport on * every update and calls controller.setViewport, snapping my pan * + zoom to theirs in lockstep. * * Follow semantics — soft and one-way. The target peer doesn't know they're * being followed (no UI, no Awareness echo). Either party panning the canvas * breaks nothing on the other end; I just keep mirroring until I click my * own avatar to release. tldraw-style. */ import type { JSX } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useViewportControllerContext } from './canvas-lib.tsx'; import { type AgentPresence, useAgentPresence } from './use-agent-presence.tsx'; import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx'; const CHROME_CSS = ` .dc-participants { position: fixed; top: 12px; right: 12px; z-index: 10000; display: flex; align-items: center; pointer-events: auto; font-family: system-ui, -apple-system, sans-serif; } .dc-participant { width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; font-size: 10px; line-height: 1; letter-spacing: 0.02em; color: var(--maude-hud-accent-fg, #fff); border: 1.5px solid var(--maude-chrome-bg-0, #fff); margin-left: -6px; cursor: pointer; position: relative; box-shadow: 0 1px 2px rgba(0,0,0,0.08); user-select: none; transition: transform 120ms ease, z-index 0ms 120ms; } .dc-participant:first-child { margin-left: 0; } .dc-participant:hover { transform: scale(1.06); z-index: 10; transition-delay: 0ms; } .dc-participant:focus-visible { outline: 2px solid var(--maude-hud-accent, oklch(56% 0.170 50)); outline-offset: 2px; } .dc-participant--following { outline: 1.5px solid var(--maude-hud-accent, oklch(56% 0.170 50)); outline-offset: 1.5px; } .dc-participant-popover { position: absolute; top: calc(100% + 8px); right: 0; min-width: 168px; padding: 10px 12px; background: var(--maude-chrome-bg-1, #fff); color: var(--maude-chrome-fg-0, #111); border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16)); border-radius: 4px; font-size: 12px; line-height: 1.35; white-space: nowrap; box-shadow: 0 6px 18px var(--maude-chrome-shadow, rgba(0,0,0,0.10)); z-index: 11; } .dc-participant-popover__name { font-weight: 600; margin-bottom: 8px; color: var(--maude-chrome-fg-0, #111); letter-spacing: 0.01em; } .dc-participant-popover__btn { display: inline-block; padding: 4px 10px; background: var(--maude-hud-accent, oklch(56% 0.170 50)); color: var(--maude-hud-accent-fg, #fff); border: none; border-radius: 2px; font-family: inherit; font-weight: 500; font-size: 11px; line-height: 1.3; cursor: pointer; letter-spacing: 0.02em; } .dc-participant-popover__btn:hover { background: var(--maude-hud-accent-hover, var(--maude-hud-accent, oklch(50% 0.170 50))); } .dc-participant-popover__btn--stop { background: color-mix(in oklab, var(--maude-chrome-fg-0, #111) 10%, transparent); color: var(--maude-chrome-fg-0, #111); border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.12)); } .dc-participant-popover__btn--stop:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #111) 16%, transparent); } @media (prefers-reduced-motion: reduce) { .dc-participant { transition: none; } } /* Phase 13.2 / DDR-078 — agent presence avatar. Looks like a peer chip + a subtle ✦ marker so an agent reads as distinct from a human collaborator. */ .dc-participant--agent { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04); } .dc-participant-agent-marker { position: absolute; right: -3px; bottom: -3px; width: 13px; height: 13px; border-radius: 50%; background: var(--maude-chrome-bg-0, #fff); color: var(--maude-chrome-fg-0, #111); display: inline-flex; align-items: center; justify-content: center; font-size: 8px; line-height: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.14); pointer-events: none; } .dc-participant-popover__sub { font-size: 11px; color: var(--maude-chrome-fg-2, #666); margin-top: -4px; margin-bottom: 6px; white-space: normal; max-width: 200px; } /* Phase 30 / DDR-120 — soft editing-presence. A peer (or a bridged agent) actively editing this canvas gets a gently-pulsing ring + a ✎ marker in the peer's OWN identity hue (--dc-edit-ring, set inline from peer.color) so the agent pulses violet, not the indigo accent. A heads-up, never a lock. */ .dc-participant--editing { animation: dc-participant-edit-pulse 1.8s ease-in-out infinite; } @keyframes dc-participant-edit-pulse { 0%, 100% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); } 50% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 4px color-mix(in oklab, var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))) 45%, transparent); } } .dc-participant-edit-marker { position: absolute; right: -3px; top: -3px; width: 13px; height: 13px; border-radius: 50%; background: var(--maude-chrome-bg-0, #fff); color: var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); display: inline-flex; align-items: center; justify-content: center; font-size: 8px; line-height: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.14); pointer-events: none; } @media (prefers-reduced-motion: reduce) { .dc-participant--editing { animation: none; box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); } } `.trim(); function ensureChromeStyles(): void { if (typeof document === 'undefined') return; if (document.getElementById('dc-participants-css')) return; const s = document.createElement('style'); s.id = 'dc-participants-css'; s.textContent = CHROME_CSS; document.head.appendChild(s); } /** * Phase 30 / DDR-120 — true when a foreign peer is THIS machine's own * server-side editing echo: a peer carrying my own name with editing set but * no cursor/selection. That's the room's projection of my own agent/inspector * edit (which I already see via the local ai-activity avatar), so I skip it. A * remote peer never matches (different name), so they still get the heads-up. */ function isOwnEditingEcho(peer: ForeignAwareness, myName: string | undefined): boolean { return peer.name === myName && !!peer.editing && !peer.cursor && !peer.selection; } function initialsFor(name: string): string { const cleaned = name.trim(); if (!cleaned) return '?'; const parts = cleaned.split(/\s+/).filter(Boolean); const first = parts[0] ?? ''; if (parts.length === 1) return first.slice(0, 2).toUpperCase(); const second = parts[1] ?? ''; return ((first[0] ?? '') + (second[0] ?? '')).toUpperCase(); } interface AvatarProps { peer: ForeignAwareness; isFollowing: boolean; onToggleFollow: (clientID: number) => void; } function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element { const [open, setOpen] = useState(false); const rootRef = useRef(null); const editing = !!peer.editing; useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { if (!rootRef.current) return; if (!rootRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [open]); const cls = `dc-participant${isFollowing ? ' dc-participant--following' : ''}${editing ? ' dc-participant--editing' : ''}`; // `--dc-edit-ring` drives the editing pulse/marker off the peer's own hue. const style = ( editing ? { background: peer.color, '--dc-edit-ring': peer.color } : { background: peer.color } ) as React.CSSProperties; return (
setOpen((v) => !v)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen((v) => !v); } }} role="button" tabIndex={0} title={editing ? `${peer.name} — editing this canvas` : peer.name} aria-label={editing ? `${peer.name}, editing this canvas` : peer.name} aria-expanded={open} > {initialsFor(peer.name)} {editing && ( )} {open && (
e.stopPropagation()}>
{peer.name}
{editing &&
Editing this canvas
}
)}
); } // Phase 13.2 / DDR-078 — an agent rendered as a presence peer. No "Follow" // (an agent has no viewport to mirror); a ✦ marker + an "AI agent" popover // subtitle distinguish it from a human collaborator. function AgentAvatar({ agent }: { agent: AgentPresence }): JSX.Element { const [open, setOpen] = useState(false); const rootRef = useRef(null); useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { if (!rootRef.current) return; if (!rootRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [open]); return (
setOpen((v) => !v)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen((v) => !v); } }} role="button" tabIndex={0} title={`${agent.name} (AI agent)`} aria-label={`${agent.name}, AI agent`} aria-expanded={open} > {initialsFor(agent.name)} ✦ {open && (
e.stopPropagation()}>
{agent.name}
AI agent · {agent.author}
)}
); } export function ParticipantsChrome(): JSX.Element | null { ensureChromeStyles(); const collab = useCollab(); const peers = useForeignAwareness(); const agent = useAgentPresence(); const controller = useViewportControllerContext(); const [followTarget, setFollowTarget] = useState(null); const onToggleFollow = useCallback( (clientID: number) => { setFollowTarget((prev) => (prev === clientID ? null : clientID)); if (collab) { collab.publishAwareness({ selection: collab.awareness.getLocalState()?.selection ?? null, }); // Tag follow target as an Awareness key so Phase 9 follow-back UIs // can read it; not currently used by anything else. const state = collab.awareness.getLocalState() ?? {}; collab.awareness.setLocalState({ ...state, followTarget: clientID === followTarget ? null : clientID, }); } }, [collab, followTarget] ); // Apply follow — when followTarget is set, mirror the target's viewport // onto our local controller. Each peer-Awareness update re-fires this; we // skip when the viewport hasn't actually changed (saves a controller write). const lastAppliedRef = useRef<{ x: number; y: number; zoom: number } | null>(null); useEffect(() => { if (followTarget === null) { lastAppliedRef.current = null; return; } if (!controller) return; const target = peers.find((p) => p.clientID === followTarget); if (!target) { // Target peer disconnected; release follow. setFollowTarget(null); return; } const v = target.viewport; const last = lastAppliedRef.current; if (last && last.x === v.x && last.y === v.y && last.zoom === v.zoom) return; lastAppliedRef.current = { x: v.x, y: v.y, zoom: v.zoom }; controller.setViewport(v); }, [controller, followTarget, peers]); // Phase 30 / DDR-120 — drop the authoring machine's own server-side editing // echo: a foreign peer carrying MY name with editing set but no cursor/ // selection is the room's projection of my own agent/inspector edit, which I // already see via the local ai-activity avatar. A remote peer never matches // (different name), so they still get the "X is editing" heads-up. const myName = collab?.myName; const visiblePeers = peers.filter((p) => !isOwnEditingEcho(p, myName)); // Render whenever there's a visible human peer OR an active agent (so an agent // shows even with no human collaborators connected). if (visiblePeers.length === 0 && !agent) return null; return (
{visiblePeers.map((p) => ( ))} {agent ? : null}
); } export { initialsFor, isOwnEditingEcho };