/** * Jam V2 in the shell (spec 27): the state glyphs and the status icon by a tag's time. * * A thread shows where work stands as one icon right of the message's time - on a tag (the last of * joined tags) - only while there is something to wait for or act on: queued, working, asks or * failed. Hovering it shows the detail in the inverse theme (the house Tip); clicking it keeps that * open with the card's one move - Drop while queued, Retry when failed. Replied, answered, done or * dropped: no icon - Marver's reply under it says it. */ import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { Tip } from './Tip.tsx' import { cardForEvent, useJamQueue, type JamCardState } from './jam-queue.ts' import { statusLines } from './jam-lines.ts' const GRAY = 'var(--glass-ink-3)' const RED = '#e5484d' // board-icons.tsx RED - errors and failures only const TEAL = '#00c3d0' // recording: Apple system teal - red is for errors const BLUE = 'var(--mv-blue, #0088ff)' const ASK = 'var(--mk-ask)' const NEW = 'var(--mk-new)' const GREEN = 'var(--status-done, #34c759)' export type JamGlyph = JamCardState | 'capturing' /** One glyph per state, spec 25's icons: ◷ queued · ◐ working · ? asks · ◉ to review (a blue disc * with an eye - a ring and dot read as a recorder) · ✓ done · – kept · ✕ failed. */ export function JamStateIcon({ state, size = 14 }: { state: JamGlyph; size?: number }) { const ring = (c: string, dash?: string, op?: number) => const line = { fill: 'none', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' } as const const cut = `jm-st-${useId().replace(/[^\w-]/g, '')}` /** a filled disc with `mark` cut out of it */ const disc = (c: string, mark: ReactNode) => (<> {mark} ) return ( {state === 'capturing' && disc(TEAL, )} {state === 'draft' && (<>{ring(ASK)})} {state === 'confirming' && (<>{ring(BLUE, undefined, .28)})} {state === 'queued' && (<>{ring(GRAY)})} {state === 'working' && (<>{ring(BLUE, undefined, .28)})} {state === 'asks' && disc(ASK, <>)} {state === 'review' && disc(NEW, <>)} {state === 'done' && disc(GREEN, )} {state === 'kept' && disc(GRAY, )} {state === 'failed' && disc(RED, )} {state === 'dropped' && (<>{ring(GRAY, '1.3 1.75')})} ) } /** The status icon right of a tag message's time. Nothing when the message carries no pending card * (or this canvas has no queue - opened elsewhere, published). */ export function TagStatus({ board, eventId, harness }: { board?: string; eventId?: string; harness: Record }) { const card = useJamQueue((s) => cardForEvent(s.cards, board, eventId)) const agent = useJamQueue((s) => s.agent) const [open, setOpen] = useState(false) const [, tick] = useState(0) const ref = useRef(null) // the working clock moves while it shows useEffect(() => { if (card?.state !== 'working') return const t = setInterval(() => tick((n) => n + 1), 1000) return () => clearInterval(t) }, [card?.state]) useEffect(() => { if (!open) return const away = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false) } window.addEventListener('pointerdown', away, true) return () => window.removeEventListener('pointerdown', away, true) }, [open]) useEffect(() => { if (!card) setOpen(false) }, [card]) // the open detail stays on screen: it sits under the icon and slides left at the viewport's edge const popRef = useRef(null) useLayoutEffect(() => { const el = popRef.current if (!el) return el.style.marginLeft = '0px' const over = el.getBoundingClientRect().right - (window.innerWidth - 8) if (over > 0) el.style.marginLeft = `${-over}px` }, [open, card?.state, card?.note]) if (!card) return null const { head, detail } = statusLines(card, agent, harness) const move = card.state === 'queued' ? 'drop' : card.state === 'failed' ? 'retry' : null const lines = {head}{detail && {detail}} return ( {open ? ( ) : ( )} {open && ( {lines} {move && } )} ) }