/** * Comment surfaces on the canvas. Canvas-first: pins live ON the frame * at their anchored elements; an inactive frame collapses its open threads into a * top-right stack; the thread card opens beside its pin. Pins keep a screen-space * size via --sh-inv (the vbadge pattern) so zoom never shrinks them away, and never * overlap (pin-layout.ts places them). */ import { TOP } from './inset.ts' import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { avatarFallback, useComments } from './comments-store.ts' import { api } from './api.ts' import { CONFIG, useStore, type Node } from './store.ts' import { threadHostKey } from './keys.ts' import { isNoteAnchor, resolveNoteAnchor, useNotes } from './notes.ts' import { canvasCtl } from './canvas/ctl.ts' import { MARKER, placeMarkers, type Placed } from './pin-layout.ts' import { bootHash, buildHash, parseHash, writeHash } from './hash.ts' import { isLegacyLink } from './legacy-links.ts' import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx' import { Tip } from './Tip.tsx' import { TagStatus } from './jam-ui.tsx' import { CaptureBlock } from './jam-panel.tsx' import { useJamQueue } from './jam-queue.ts' import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts' import { slideSize, stageFit } from '../const.ts' import type { AgentMeta, Thread } from '../../shared/events.ts' /** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored * element's own laser hue (captured at pick as anchor.el.hue) via --cm-line/--cm-line-ring. * Deliberately NOT the button fills (--comment stays green: white text on a saturated hue * fails at yellow/green/cyan) and NOT the avatar (keeps its per-user colour). No hue -> the * CSS falls back to the green token. */ export function hueVars(hue?: number): React.CSSProperties { // guard NaN / Infinity from a bad persisted anchor - an invalid custom property would // suppress the CSS fallback and leave the edge uncoloured if (!Number.isFinite(hue)) return {} const h = hue as number // --cm-solid is the FILLED-button colour (send / sign-in): a hue-aware lightness so the // white glyph keeps contrast - yellow/green/cyan read light, so they go darker. const l = (h >= 40 && h < 75) ? 34 : (h >= 75 && h < 165) ? 36 : (h >= 165 && h < 200) ? 38 : 45 return { '--cm-line': `hsl(${h} 95% 45%)`, '--cm-line-ring': `hsl(${h} 95% 50% / .30)`, '--cm-solid': `hsl(${h} 85% ${l}%)`, } as React.CSSProperties } const anchorHue = (a: unknown): number | undefined => (a as any)?.el?.hue const rel = (ts: number) => { const m = Math.round((Date.now() - ts) / 60_000) if (m < 1) return 'now' if (m < 60) return `${m}m` if (m < 24 * 60) return `${Math.round(m / 60)}h` return `${Math.round(m / 1440)}d` } /** The Marver mark avatar: accent-blue disc + the solid white parallelogram. One place, reused * by the thread header, the pin/stack marker, and the notification pill. */ export function MarverAvatar({ size = 24 }: { size?: number }) { // 0.5 keeps clear blue padding around the mark even in the marker, where a 2px white border // shrinks the visible disc (a bigger mark looked nearly cut there). return ( ) } export function Avatar({ author, size = 22 }: { author?: { email?: string; name?: string; avatar?: string; agent?: boolean }; size?: number }) { const me = useComments((s) => s.me) const local = useComments((s) => s.local) if (author?.agent) return // Marver shows as its own participant // dev: an account-less author IS the person at this keyboard - render the CURRENT identity, // not the snapshot the event happened to store (old "Designer" events become you, live) const a = local && me && !author?.email ? { ...author, ...me } : author if (a?.avatar) return const { initials, hue } = avatarFallback(a) return ( {initials} ) } type Face = { email?: string; id?: string; name?: string; avatar?: string; agent?: boolean } /** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments * in total - root + every reply, across all the given threads. Unique participants * in first-seen order; a single reply-less comment counts as 1. */ function markerDigest(threads: Thread[]): { faces: Face[]; count: number } { const seen = new Map() let count = 0 // namespace the identity key so an email can never collide with someone's name, and normalize // email case. An AGENT message is its own participant ("Marver"), NOT the owner who authored // it - so the marker shows N + Marver. Every email-less human face collapses to ONE key: in // dev those snapshots ("Designer", "You", nothing) are all the person at this keyboard, and // Avatar renders them all as the live identity - two keys would show identical twin faces. const add = (a: Face | undefined, agent?: boolean) => { const face: Face | undefined = agent ? { agent: true } : a // published transport carries the opaque id instead of an email - same // namespacing rule, different stable key const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : a?.id ? `i:${a.id}` : 'local' if (face && !seen.has(k)) seen.set(k, face) } for (const t of threads) { count += 1 + t.replies.length add(t.author, t.agent) for (const r of t.replies) add(r.author, r.agent) } return { faces: [...seen.values()], count } } /** The face of a marker: up to `max` overlapping avatars + the total count when >1. * One shape for pins (one thread, anchored) and stacks (all threads on a frame). */ export function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) { const { faces, count } = markerDigest(threads) return ( <> {faces.slice(0, max).map((a, i) => )} {count > 1 && {count}} ) } type Point = { x: number; y: number } /** Open threads in creation order - the order markers are placed in, so a newer one moves, never * an older one. */ export const byCreation = (threads: Thread[]): Thread[] => [...threads].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1)) /** Place one surface's markers - a canvas frame (node px) or the stage (screen px, inv 1): fixed * points, a fixed marker size, the column for threads with no point. It reads nothing a selection * or an open card changes, so neither ever moves a marker. */ export const placePins = (threads: Thread[], points: Map, frameW: number, inv: number) => placeMarkers(byCreation(threads).map((t) => ({ id: t.id, point: points.get(t.id) ?? null })), frameW, inv) /** The placed markers, with a thin line from each one that had to move to its point. */ export function Pins({ threads, placed, orphans, inv, active, alertIds }: { threads: Thread[]; placed: Map; orphans: Set inv: number; active: string | null; alertIds?: Set }) { const { setActive } = useComments.getState() // a pin wears the thin Marver-blue working ring while the agent works on its thread (spec 27) const working = useJamQueue((s) => s.cards)?.filter((c) => c.state === 'working').map((c) => c.thread) ?? [] return ( <> {threads.map((t) => { const p = placed.get(t.id)! const orphan = orphans.has(t.id) return [ p.lead && ,
e.stopPropagation()} onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }} title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
, ] })} ) } /** Lives inside FrameNode's body, over the iframe. Owns anchor resolution for its frame. */ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: string; iframe: React.RefObject }) { const show = useComments((s) => s.show) const showAnchor = useComments((s) => s.showAnchor) const active = useComments((s) => s.active) const draft = useComments((s) => s.draft) // select the stable array, filter in render - a selector returning a fresh // .filter() array re-renders forever (Object.is never matches) const allThreads = useComments((s) => s.threads) const allNodes = useStore((s) => s.nodes) // one owner per thread (keys.ts): the stored anchor when it still holds, else the // first node showing the frame - a stale key (pre-0.8.1 random keys, board rewrites, // deleted copies) degrades to the frame, never to invisible, and never to two pins const threads = allThreads.filter((t) => t.frame === frameId && threadHostKey(t, allNodes) === node.key) const selected = useStore((s) => s.selection.includes(node.key)) const { setActive } = useComments.getState() // each in-frame thread's attach point, in the frame's px - null when its element is gone const [framePoints, setFramePoints] = useState>({}) // the zoom markers are placed at: the store commits it once a zoom settles, never mid-gesture, // so markers hold still while you zoom and land once const scale = useStore((s) => s.scale) // a slide renders at its stage and the node scales it (FrameNode): rects the frame reports are // in stage px, so they map through the same fit into the node's coordinates const frameEntry = useStore((s) => s.frameFor(node)) const stage = frameEntry ? slideSize(frameEntry, CONFIG.viewports) : null const fit = stage ? stageFit(stage, { w: node.w, h: node.h }) : null const inNode = (r: R): R => fit ? { ...r, x: fit.ox + r.x * fit.k, y: fit.oy + r.y * fit.k, w: r.w * fit.k, h: r.h * fit.k } : r const pointInNode = (p: Point): Point => (fit ? { x: fit.ox + p.x * fit.k, y: fit.oy + p.y * fit.k } : p) const open = threads.filter((t) => !t.resolved) const anchored = open.filter((t) => (t.anchor as any)?.el) // whether the ACTIVE thread is still open on this frame - a dep for the highlight effect so // a remote resolve (thread leaves `open` while `active` is unchanged) still clears the lock const activeShown = !!active && open.some((t) => t.id === active) // anchors split by where they live: in the frame document (inspect.js resolves them over // postMessage) or on a sticky note (spec 18: shell DOM, resolved right here) const inFrame = anchored.filter((t) => !isNoteAnchor(t.anchor)) const onNote = anchored.filter((t) => isNoteAnchor(t.anchor)) // resolve anchors against the live frame whenever threads, their anchors (a re-pin) or the // document change useEffect(() => { const win = iframe.current?.contentWindow if (!win || node.status !== 'ready' || !inFrame.length) return const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: inFrame.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin) const onMsg = (e: MessageEvent) => { if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects') return const next: typeof framePoints = {} for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.point setFramePoints((cur) => (JSON.stringify(cur) === JSON.stringify(next) ? cur : next)) } window.addEventListener('message', onMsg) ask() const iv = setInterval(ask, 4000) // re-renders, scroll, hot reloads - cheap to re-ask return () => { clearInterval(iv); window.removeEventListener('message', onMsg) } }, [node.status, inFrame.map((t) => `${t.id}:${JSON.stringify(t.anchor)}`).join(','), iframe]) // note anchors: their own map, measured in the shell, in node-body coordinates (negative x - // the note is left of the frame). The composing draft is measured too (key 'draft'), so it // follows a fold like a saved pin. A folded column parks pins on the fold; a note whose file // is gone, or whose element the markdown no longer has, parks like an orphan. Re-measured on // every fold/unfold (twice: at once and past the transition) and on the same 4 s beat. const [noteRects, setNoteRects] = useState>({}) const notesState = useNotes() const draftOnNote = draft?.nodeKey === node.key && isNoteAnchor(draft.anchor) ? draft.anchor : null useEffect(() => { if (!onNote.length && !draftOnNote) { setNoteRects((r) => (Object.keys(r).length ? {} : r)); return } const measure = () => { const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`) as HTMLElement | null const origin = host?.querySelector('.cm-layer')?.getBoundingClientRect() if (!host || !origin) return const s = origin.width ? origin.width / host.offsetWidth : 1 const toBody = (r: DOMRect) => ({ x: (r.left - origin.left) / s, y: (r.top - origin.top) / s, w: r.width / s, h: r.height / s }) const column = host.querySelector('.sh-notes') const fold = column?.querySelector('.sh-notes-fold') const place = (a: any) => { const body = column?.querySelector(`[data-sticky="${a.el.note}"] .sh-sticky-body`) if (!body) return null if (column!.classList.contains('off')) return fold ? toBody(fold.getBoundingClientRect()) : null const el = resolveNoteAnchor(a, body) return el ? toBody(el.getBoundingClientRect()) : null } const next: typeof noteRects = {} for (const t of onNote) next[t.id] = place(t.anchor) if (draftOnNote) next.draft = place(draftOnNote) setNoteRects(next) } measure() const late = setTimeout(measure, 260) const iv = setInterval(measure, 4000) return () => { clearTimeout(late); clearInterval(iv) } }, [onNote.map((t) => t.id).join(','), draftOnNote, notesState.all, notesState.hidden.join(','), node.key, node.w, node.h]) /** This node's sticky column when it is showing: the docked card must clear it (world px). */ const noteColumn = (): HTMLElement | null => document.querySelector(`[data-node-notes="${CSS.escape(node.key)}"]:not(.off)`) // #4/#5: drive the persistent element highlight into this frame - the composing draft on // this node (lock on pick), or the open thread anchored here (highlight on open). null // clears it, so the shell owns the release; on a failed/late resolve the frame re-applies // on its next anchor-resolve tick. The self-lock on pick handles the instant case. useEffect(() => { const win = iframe.current?.contentWindow if (!win || node.status !== 'ready') return // ONLY light an element while its thread is the active one (or a draft is composing on // it) AND pins are shown AND element focus is on (⇧L) - closing the thread, hiding // comments (⇧C), or dimming focus clears it, so a stray outline never lingers. const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined const anchor = show && showAnchor ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: isNoteAnchor(anchor) ? null : anchor ?? null }, location.origin) // a note anchor lights its element in the shell the way the frame lights its own const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`) for (const el of host?.querySelectorAll('[data-sh-lock]') ?? []) el.removeAttribute('data-sh-lock') if (isNoteAnchor(anchor)) { const body = host?.querySelector(`[data-sticky="${anchor.el.note}"] .sh-sticky-body`) const el = body && resolveNoteAnchor(anchor, body) if (el) el.setAttribute('data-sh-lock', '') } // active/draft/show/showAnchor/activeShown and the active anchor (a re-pin) are the triggers; // `open` is read fresh from the closure }, [active, draft, show, showAnchor, activeShown, JSON.stringify(open.find((t) => t.id === active)?.anchor ?? null), node.status, node.key, frameId, iframe]) // the open card's frozen side (D3) - a HOOK, so it must live above the early returns; and the // reset must too: a card can close via the inactive-frame early return (clicking another frame), // which never reaches the render tail - reopen must still RE-PICK the side const sideRef = useRef<{ id: string; side: 'l' | 'r' } | null>(null) if (sideRef.current && sideRef.current.id !== active) sideRef.current = null // what occupies this frame's LEFT flank (drives the docked card's left gutter): a variant // badge is widest, the working shimmer next. Reactive, so a job starting mid-read adjusts. const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup) const flankShim = useStore((s) => s.working.includes(frameId)) // (a sticky column is read from the DOM at render: it is per viewer and per host node) const drafting = draft?.nodeKey === node.key const alertIds = useMentionAlerts() if (!show || (!open.length && !drafting)) return null // inactive frame: the stack - count + avatars, top-right const engaged = selected || open.some((t) => t.id === active) || drafting if (!engaged && open.length) { return ( ) } // each open thread's attach point in node px: a note's where it was clicked on the note, a // frame's where the frame says; none (the column) for a legacy frame-level pin, an orphan, or // an anchor not resolved yet const points = new Map() const orphans = new Set() for (const t of open) { const a = t.anchor as any if (!a?.el) continue const r = isNoteAnchor(a) ? noteRects[t.id] : undefined const p = isNoteAnchor(a) ? (r ? { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h } : r) : framePoints[t.id] && pointInNode(framePoints[t.id]!) if (p === null) orphans.add(t.id) points.set(t.id, p ?? null) } const inv = 1 / Math.max(0.01, scale) const placed = placePins(open, points, node.w, inv) const pinAt = (t: Thread): Placed => placed.get(t.id)! // The card's SIDE, chosen ONCE per open and frozen (D3 - never switches mid-read): the pin's // side of the frame first, skipping a side another frame's screen rect occupies, then wherever // the viewport has room. The active pin's teardrop tail flips toward this side. const activeThread2 = active ? open.find((t) => t.id === active) : undefined if (activeThread2 && sideRef.current?.id !== activeThread2.id) { const compute = (): 'l' | 'r' => { const W = 320, GAP = 30 const rect = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)?.getBoundingClientRect() if (!rect) return 'r' // a showing sticky column sits in the left gutter in WORLD px (its width + 36, the CSS // margin): the card lands beyond it, so both room and collision use that screen offset const scale = rect.width / Math.max(1, node.w) const col = noteColumn() const gutter = col ? col.getBoundingClientRect().width + 36 * scale : 0 const occupied = (s: 'l' | 'r') => { const rx = s === 'r' ? rect.right + 10 : rect.left - gutter - 10 - W return [...document.querySelectorAll('.sh-node, .sh-notes:not(.off)')].some((n) => { if (n.getAttribute('data-node') === node.key || n.getAttribute('data-node-notes') === node.key) return false const r = n.getBoundingClientRect() return r.left < rx + W && r.right > rx && r.top < rect.bottom && r.bottom > rect.top }) } // the left room starts at the sidebar's edge, not the window's: a card parked under the // panel is a card nobody can read (a note's gutter makes that reach likelier) const panelRight = document.querySelector('.sh-panel:not(.closed)')?.getBoundingClientRect().right ?? 0 const room = (s: 'l' | 'r') => (s === 'r' ? window.innerWidth - rect.right : rect.left - gutter - panelRight) >= W + GAP const prefer: ('l' | 'r')[] = pinAt(activeThread2).x < node.w / 2 ? ['l', 'r'] : ['r', 'l'] return prefer.find((s) => room(s) && !occupied(s)) ?? prefer.find(room) ?? prefer[0] } sideRef.current = { id: activeThread2.id, side: compute() } } const cardSide: 'l' | 'r' = sideRef.current?.side ?? 'r' return ( <> {activeThread2 && ( )} {draft?.nodeKey === node.key && (() => { const a = draft.anchor as any const r = (draftOnNote && noteRects.draft) || (a?.rect && inNode(a.rect)) return })()} ) } /** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and * prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */ // ---- Live Jam: @marver rendering + Marver identity ---------------------- const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it." /** The mentionable set, derived from everything the client has seen (published: * projected ids + names; dev: canonical emails). The viewer themself is out - * this is the COMPOSER's set (you cannot mention yourself). */ function usePeople(): MentionPerson[] { const events = useComments((s) => s.events) const me = useComments((s) => s.me) return mentionPeople(events, me ?? undefined) } /** Everyone, self included - the RENDERING set: a mention of you must light up * hardest of all, not disappear (the bug that shipped: the excluded self made * your own mentions render as plain text exactly for the person they were for). */ function useRenderPeople(): { people: MentionPerson[]; isMe: (p: MentionPerson) => boolean } { const events = useComments((s) => s.events) const me = useComments((s) => s.me) const people = useMemo(() => mentionPeople(events), [events]) const isMe = (p: MentionPerson) => (!!p.id && p.id === me?.id) || (!!p.email && !!me?.email && p.email.toLowerCase() === me.email.toLowerCase()) return { people, isMe } } /** Threads holding an unseen mention of me - drives the pin/stack pulse. */ function useMentionAlerts(): Set { const events = useComments((s) => s.events) const me = useComments((s) => s.me) const seen = useComments((s) => s.mentionSeen) return useMemo(() => new Set(mentionAlerts(events, me, seen)), [events, me, seen]) } /** A comment body with mentions styled. `@marver`: owner-authored → bold accent (a live * trigger); anyone else's → plain + a teaching tooltip (context, not a command). * `@Person` (v1.1) → the person chip; the same segmentation that derived the * event's mentions, so what lit up is what notified. */ function CommentBody({ body, owner }: { body?: string; owner: boolean }) { const { people, isMe } = useRenderPeople() if (!body) return null return (

{parseBody(body, people).map((s, i) => s.person ? {s.text} : !s.marver ? {s.text} : owner ? {s.text} : {AT_TIP}}>{s.text})}

) } export const HARNESS: Record = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', opencode: 'opencode', droid: 'Factory Droid', grok: 'Grok', pi: 'pi' } /** claude-opus-5 → Opus 5; gpt-5.1-codex → Gpt 5.1 Codex; unknown → as-is. */ function prettyModel(m: string): string { const cleaned = m.replace(/^claude-/, '').replace(/-/g, ' ') return cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) } /** The provenance tooltip on the Marver avatar: who orchestrated the change. * One row per fact, left-aligned, bold label + regular value - the house tooltip treatment. */ function AgentMetaTip({ meta }: { meta?: AgentMeta }) { const rows = [ meta?.devUser && ['Dev user', meta.devUser], meta?.harness && ['Harness', HARNESS[meta.harness] ?? meta.harness], meta?.model && ['Model', prettyModel(meta.model)], meta?.effort && ['Effort', meta.effort], ].filter(Boolean) as [string, string][] if (!rows.length) return Marver // Toolbar-tooltip treatment: the VALUE is bold + bright (what you scan), the label is a muted // qualifier after it - like "Hide all UI press H to reveal". return ( {rows.map(([label, value]) => {value}{label})} ) } /** A message header. Agent messages render as "Marver" with the mark + provenance tooltip; * human messages keep their avatar + name. While the dev profile is still UNSET, every own * message avatar invites editing on hover (discoverability); once set, only the composer's * avatar is the door. */ function MessageHead({ author, agent, agentMeta, ts, board, eventId }: { author?: Thread['author']; agent?: boolean; agentMeta?: AgentMeta; ts: number; board?: string; eventId?: string }) { const me = useComments((s) => s.me) const local = useComments((s) => s.local) const connected = useComments((s) => s.connected) if (agent) return (
}> Marver {rel(ts)}
) // own = born on this machine without an account (no email); unset = identity still the default. // Own messages render the CURRENT identity, not the stored snapshot - same rule as Avatar. const unset = local && !connected && (!me?.name || me.name === 'You') const own = local && !author?.email const shown = own && me ? { ...author, ...me } : author return (
{unset && own ? : } {shown?.name ?? 'Someone'} {rel(ts)}
) } /** An auto-growing composer with the full keybindings: Enter send, Shift+Enter newline, * Cmd/Ctrl+Enter send, Escape cancel; IME-guarded, disabled while a send is in flight. */ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFocus, sendLabel, owner }: { value: string; onChange: (v: string) => void; onSubmit: () => void | Promise onCancel?: () => void; placeholder: string; autoFocus?: boolean; sendLabel: string; owner: boolean }) { const ref = useRef(null) const hlRef = useRef(null) const [busy, setBusy] = useState(false) // ---- @-mention typeahead (v1.1): the token under the caret filters the people the // client has seen; picking completes `@Label ` in place. Escape dismisses THIS token // only (the composer keeps its own Escape = cancel semantics when no list is open). const people = usePeople() const [caret, setCaret] = useState(0) const [pick, setPick] = useState(0) const [dismissed, setDismissed] = useState(null) const q = mentionQueryAt(value, caret) const matches = q && dismissed !== q.start ? people.filter((p) => p.label.toLowerCase().startsWith(q.query.toLowerCase())).slice(0, 5) : [] const listOpen = matches.length > 0 useEffect(() => { setPick(0) }, [q?.start, q?.query]) const track = () => setCaret(ref.current?.selectionStart ?? 0) const choose = (p: MentionPerson) => { if (!q) return const pos = q.start + p.label.length + 2 // '@' + label + ' ' onChange(value.slice(0, q.start) + '@' + p.label + ' ' + value.slice(caret)) setCaret(pos) requestAnimationFrame(() => { const el = ref.current; if (el) { el.focus(); el.setSelectionRange(pos, pos) } }) } // +2: scrollHeight excludes the 1px borders, but border-box height includes them - without // it every line is squeezed 2px and the single-line pill renders off-center const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } } const syncScroll = () => { if (hlRef.current && ref.current) hlRef.current.scrollTop = ref.current.scrollTop } useEffect(() => { if (autoFocus) ref.current?.focus() }, [autoFocus]) useEffect(grow, [value]) const submit = async () => { if (busy || !value.trim()) return setBusy(true) try { await onSubmit() } finally { setBusy(false) } } return (
{/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver shows its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted) and a completed @Person wears the chip it will render with. */}
{parseBody(value, people).map((s, i) => s.person ? {s.text} : s.marver ? {s.text} : {s.text})} {value.endsWith('\n') ? '​' : ''}
{listOpen && (
{matches.map((p, i) => ( ))}
)}