/**
* 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 (
)
}
/** 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 && }
)}
)
}