/**
* Jam's surfaces in the shell (spec 27): the lane along the bottom (captures only, in the order made,
* the one recording last), the one focus panel (a capture's detail - a draft's read-back - or a
* review), the notes card over the capture tile, the teal edge ring and the numbered points while a
* capture records. The pieces are presentational (jam-lane.tsx, jam-panel.tsx); this file feeds
* them from the three stores and turns their clicks into queue actions.
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ROUTE } from '../const.ts'
import { captureCounts, useJamCapture } from './jam-capture.ts'
import { useJamVoice, voiceSession, voiceSessionEnd, voiceSessionId, type Heard } from './jam-voice.ts'
import { holdsSessionMic, PlayCounter, RecordDetail, StartSheet, WrapSheet } from './jam-session.tsx'
import { FocusPanel, VoiceAsk } from './jam-panel.tsx'
import { addNote, decideDraft, laneCards, openThread, openTile, pendingEdit, resumeDraft, reveal, saveEdit, shownLane, startCapture } from './jam-keys.ts'
import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu.tsx'
import { ArrowBendUpLeftIcon, ArrowLineUpIcon, ArrowUpIcon, CheckSquareOffsetIcon, CommentIcon, EyeIcon, NotebookIcon, PencilSimpleIcon, PlayIcon, ReloadIcon, XIcon } from './icons.tsx'
import { laneLine, timelineLines } from './jam-lines.ts'
import { changedShots, useJamQueue, type JamCard } from './jam-queue.ts'
import { DONE_GROUP, doneFold, letGoOfTile, useJamUi } from './jam-state.ts'
import { CaptureLaneRow, FocusPanelView } from './jam-surface-parts.tsx'
import { clearMoreDraft, EdgeRing } from './jam-panel.tsx'
import { useComments } from './comments-store.ts'
import { HARNESS } from './Comments.tsx'
import { PUBLISHED, useStore } from './store.ts'
import { api, withWs } from './api.ts'
import type { CaptureRecord } from '../../server/jam/types.ts'
const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
/** An cannot send a header: its URL names the workspace, which the server's guard reads (A1.11). */
const picture = (id?: string) => (id ? withWs(`${ROUTE}/api/jam/picture?id=${encodeURIComponent(id)}`) : undefined)
/** Is the lane showing? While a run lasts with a capture in it, or a capture records. */
export function useLaneShown(): boolean {
const cards = useJamQueue((s) => s.cards)
const run = useJamQueue((s) => s.run)
// a capture recording, sealing or waiting to send keeps the lane up until its server card arrives
const busy = useJamCapture((s) => !!s.current || Object.keys(s.caps).length > 0 || s.unsent.length > 0)
// a session keeps it up from start to end: between captures its own tile says it is still on
const session = useJamQueue((s) => !!s.session)
return !PUBLISHED && (busy || session || laneCards(cards, run).length > 0)
}
/** A capture's record, loaded once per id for its detail. */
function useRecord(id: string | undefined) {
const [rec, setRec] = useState<{ id: string; record: CaptureRecord; edited?: string } | null>(null)
useEffect(() => {
if (!id || rec?.id === id) return
let live = true
api(`jam/capture?id=${encodeURIComponent(id)}`, { headers: { 'x-mv-c': csrf() } })
.then((r) => (r.ok ? r.json() : null)).then((r) => { if (live && r?.record) setRec({ id, record: r.record, edited: r.edited }) }, () => {})
return () => { live = false }
}, [id])
return rec?.id === id ? rec : null
}
export function JamSurface({ sidebarRight }: { sidebarRight: number }) {
const cards = useJamQueue((s) => s.cards)
const run = useJamQueue((s) => s.run)
const agent = useJamQueue((s) => s.agent)
const ui = useJamUi()
const cap = useJamCapture()
const voice = useJamVoice()
const shown = useLaneShown()
const session = useJamQueue((s) => s.session)
const playing = useStore((s) => !!s.play)
const [, setTick] = useState(0)
// the capture tile's timer and a working card's clock move
useEffect(() => { const t = setInterval(() => setTick((n) => n + 1), 1000); return () => clearInterval(t) }, [])
// a new capture or a new draft brings the lane back to its right end
useEffect(() => { ui.follow() }, [cap.current, cards?.filter((c) => c.kind === 'capture').length])
// the read-back of the capture just ended opens when capturing stops - over a review too (a capture
// made during a review is confirmed in its read-back; the review it follows stays bound in its record).
// It waits for that capture's own card: a resumed one replaces its draft, which must not open instead
// the capture ENDED last, not the one that sealed last (pictures can keep an earlier one sealing longer)
const [shownFor, setShownFor] = useState(null)
useEffect(() => {
if (cap.current || !cap.lastEnded || cap.lastEnded === shownFor) return
const sealed = cap.sealed.find((x) => x.id === cap.lastEnded)
if (!sealed?.card) return
const draft = cards?.find((c) => c.id === sealed.card!.id && c.state === 'draft')
if (!draft) return
setShownFor(cap.lastEnded)
if (ui.panel?.kind === 'capture' && ui.panel.id === draft.id) return
ui.open({ kind: 'capture', id: draft.id })
reveal(draft)
}, [cap.current, cap.sealed, cap.lastEnded, cards])
// the session as the server holds it: a page opened (or reloaded) during one hears its talk again; one
// ended elsewhere (another tab) stops the talk here
useEffect(() => {
// only the tab that holds the session's microphone hears its talk - another tab never takes it over
const heard = voiceSessionId()
if (session && heard !== session.id && holdsSessionMic(session.id)) void voiceSession(session.id, performance.timeOrigin + performance.now())
// the talk of a session that is not the server's (ended, or another one) stops here
else if (heard && heard !== session?.id) void voiceSessionEnd()
}, [session?.id])
// J pressed while a capture recorded: the wrap-up waits for that capture's read-back to have opened and
// closed (or for it to have no draft at all - dropped, or kept at once)
useEffect(() => {
if (ui.sheet !== 'wrap-pending' || cap.current || Object.keys(cap.caps).length || cap.unsent.length) return
const id = cap.lastEnded
const card = id ? cards?.find((c) => c.id === id) : undefined
// sealed but its card not here yet: wait for it (a dropped capture never sealed - nothing to wait for)
if (id && !card && cap.sealed.some((x) => x.id === id)) return
// a draft: its read-back first - opened, then closed (confirmed, kept, dropped, or set aside)
if (card && card.state === 'draft' && (shownFor !== id || ui.panel?.id === id)) return
ui.setSheet('wrap')
}, [ui.sheet, ui.panel, cap.current, cap.caps, cap.unsent, cap.lastEnded, cards, shownFor])
// a draft being resumed is the capture recording (or sealing) now: its own tile steps aside
const list = shownLane(cards, run, cap.resuming)
const cm = useContextMenu()
// a comment opening (a pin, a thread, Open thread) takes the place of the read-back or review: the panel
// closes; the tile lets go too, unless the thread is that tile's own (a capture that asks opens its thread)
useEffect(() => useComments.subscribe((s, p) => {
if (!s.active || s.active === p.active) return
const st = useJamUi.getState()
const sel = useJamQueue.getState().cards?.find((c) => c.id === st.selected)
if (sel?.thread === s.active) { if (st.panel) st.close() } else letGoOfTile()
}), [])
const made = (id: string) => Number(/^cap-(\d+)-/.exec(id)?.[1] ?? 0)
const withMade = list.map((c) => ({ made: c.made, tile: { id: c.id, state: c.state, title: c.title ?? 'Capture', line: laneLine(c), ...(c.exclusive ? { exclusive: true } : {}), ...(c.state === 'done' && c.shots?.some((x) => !x.same) ? { frames: c.shots.filter((x) => !x.same).map((x) => x.frame) } : {}), ...(c.kind === 'wrapup' ? { record: true } : {}) } as { id: string; state: JamCard['state']; title: string; line: string; exclusive?: boolean; frames?: string[]; record?: boolean } }))
// captures the server does not hold yet: sealing (pictures and flushes still out) or waiting to send -
// each in its place by when it started, so one sealing late never swaps with a newer one
const known = new Set(list.map((c) => c.id))
for (const c of Object.values(cap.caps)) if (c.state === 'draining' && !known.has(c.id)) withMade.push({ made: made(c.id), tile: { id: c.id, state: 'confirming', title: 'Capture', line: 'Finishing' } })
for (const id of cap.unsent) if (!known.has(id) && !cap.caps[id]) withMade.push({ made: made(id), tile: { id, state: 'confirming', title: 'Capture', line: 'Waiting to send' } })
const tiles = withMade.sort((a, b) => a.made - b.made).map((x) => x.tile)
// a selected tile that became done while the Done group is folded: the group holds the selection now
const doneNow = doneFold(tiles, ui.doneOpen)
useEffect(() => {
if (doneNow.fold && !ui.doneOpen && ui.selected && doneNow.done.some((t) => t.id === ui.selected)) ui.select(DONE_GROUP)
}, [doneNow.fold, ui.doneOpen, ui.selected, doneNow.done.length])
const recording = cap.current ? (() => {
const c = cap.caps[cap.current!]
const counts = captureCounts(c)
const steps = (c?.steps ?? []).filter((x) => ['select', 'device', 'theme', 'copied', 'interact'].includes(x.event)).length
return { time: counts.time, points: c?.points.length ?? 0, pictures: c?.pictures.length ?? 0, picturesMax: (c?.pictures.length ?? 0) >= 12, steps,
notes: counts.notes, noteOn: cap.noteOpen, onNote: () => cap.setNoteOpen(!cap.noteOpen), onStop: () => cap.end(),
// the microphone: on while it hears this capture; off once you were asked (a click asks again)
...(voice.hearing === cap.current ? { mic: 'on' as const } : voice.choice !== null || voice.denied ? { mic: 'off' as const, onMic: () => useJamVoice.setState({ asking: true, choice: null }) } : {}) }
})() : undefined
// a session between captures (none recording, none still sealing here): its own tile
const between = session && !cap.current ? {
since: session.since, mic: voice.live,
onCapture: () => startCapture(), // as S: one started over an open review follows it
} : undefined
const panelCard = ui.panel ? cards?.find((c) => c.id === ui.panel!.id) : undefined
const rec = useRecord(ui.panel?.kind === 'capture' || ui.panel?.kind === 'review' ? ui.panel.id : undefined)
const agentName = agent ? HARNESS[agent] ?? agent : 'your agent'
const act = (c: JamCard | undefined, action: Parameters['act']>[1], body?: string) => (c ? useJamQueue.getState().act(c, action, body) : Promise.resolve(false))
const openThread = (c?: JamCard) => { if (c?.thread) useComments.getState().setActive(c.thread) }
/** The panel's arrows: the previous or next card of the same kind on the lane (reviews among reviews). */
const step = (d: number) => {
if (!ui.panel || !panelCard) return undefined
const of = list.filter((c) => (ui.panel!.kind === 'review' ? c.state === 'review' : true))
const n = of[of.findIndex((c) => c.id === panelCard.id) + d]
return n ? () => openTile(n.id) : undefined
}
const detail = useMemo(() => {
if (!ui.panel || !panelCard) return null
const of = list.filter((c) => (ui.panel!.kind === 'review' ? c.state === 'review' : true))
const idx = of.findIndex((c) => c.id === panelCard.id)
return { of: [idx + 1, of.length] as [number, number] }
}, [ui.panel, panelCard, list])
const sheetOpen = ui.sheet === 'start' || ui.sheet === 'wrap'
// the microphone question in a session with no capture recording: under the Jam button
const askAlone = voice.asking && !cap.current && !!session
if (!shown && !ui.panel && !sheetOpen && !askAlone) return null
// in play the lane folds to a counter beside the navigator (Q opens it); the capture recording stays
const folded = playing && !ui.laneKeys
return (
<>
{/* the edges glow while a capture records - and for the whole session, between captures too */}
{(cap.current || session) && }
{ui.sheet === 'start' &&
}
{ui.sheet === 'wrap' &&
}
{askAlone && !sheetOpen &&
}
{folded && shown && }
{shown && (
ui.setDoneOpen(open, ids)}
onOpen={(id) => openTile(id)}
onMenu={(e, id) => { const c = cards?.find((x) => x.id === id); if (c) { ui.select(id); cm.open(e, tileMenu(c)) } else e.preventDefault() }}
ask={voice.asking && cap.current ? voiceAsk(voice) : undefined}
caption={cap.current ? captionOf(voice.heard[cap.current]) : undefined}
notes={cap.noteOpen ? {
notes: (cap.current ? cap.caps[cap.current]?.steps ?? [] : []).filter((x) => x.event === 'note').map((x) => ({ t: captureCounts({ ...cap.caps[cap.current!]!, t0: cap.caps[cap.current!]!.t0 }, x.at).time, text: (x as { text: string }).text })),
draft: cap.noteDraft, onDraft: cap.setNoteDraft, onAdd: () => cap.note(cap.noteDraft), onClose: () => cap.setNoteOpen(false),
} : undefined}
/>
)}
{ui.panel && panelCard && panelCard.kind === 'wrapup' && (
ui.close()}>
ui.setMoreOpen(on)}
onGood={() => { void act(panelCard, 'good'); ui.close() }}
onLater={() => { void act(panelCard, 'later'); ui.close() }}
onRetry={() => { void act(panelCard, 'retry'); ui.close() }}
onDrop={() => { void act(panelCard, 'drop'); ui.close() }}
onMore={(text) => void (async () => {
if (await act(panelCard, 'more', text)) { clearMoreDraft(panelCard.id); ui.setMoreOpen(false); ui.close() }
else useStore.getState().toast('your note was not sent - try again')
})()} />
)}
{ui.panel && panelCard && panelCard.kind !== 'wrapup' && (
c.id === panelCard.reviewing!.card)?.title : undefined}
lines={rec ? timelineLines(rec.record, panelCard.edited ?? rec.edited) : []}
pictures={(rec?.record.pictures ?? []).map((id, i) => ({ id, url: picture(id)!, caption: `picture ${i + 1}` }))}
shots={changedShots(panelCard.shots).map((x) => ({ frame: x.frame, before: picture(x.before), after: picture(x.after), ...(x.new ? { new: true } : {}) }))}
reply={panelCard.reply ? replyText(panelCard) : ''}
view={ui.view} tab={ui.tab} moreOpen={ui.moreOpen}
onTab={(i) => ui.setTab(i)} onFlip={(v) => ui.setView(v)} onMoreOpen={(on) => ui.setMoreOpen(on)}
onClose={() => ui.close()}
onEdit={(text) => saveEdit(panelCard, text)}
onConfirm={() => void decideDraft(panelCard.id, 'confirm')}
onKeep={() => void decideDraft(panelCard.id, 'keep')}
onDrop={() => void decideDraft(panelCard.id, 'drop')}
onResume={() => void resumeDraft(panelCard.id)}
onAddNote={async (text, id) => { const ok = await addNote(panelCard, text, id); if (!ok) useStore.getState().toast('the note was not added - try again'); return ok }}
onGood={() => { void act(panelCard, 'good'); ui.close() }}
onMore={(text) => void (async () => {
// the composer and its words stay until the follow-up is really queued
if (await act(panelCard, 'more', text)) { clearMoreDraft(panelCard.id); ui.setMoreOpen(false); ui.close() }
else useStore.getState().toast('your follow-up was not sent - try again')
})()}
onLater={() => { void act(panelCard, 'later'); ui.close() }}
onOpenThread={() => openThread(panelCard)}
onPrev={step(-1)} onNext={step(1)} pending={pendingEdit(panelCard.id)} locked={cap.settling === panelCard.id}
onRetry={() => void (async () => { if (await act(useJamQueue.getState().cards?.find((x) => x.id === panelCard.id), 'retry')) ui.close(); else useStore.getState().toast('the capture could not be retried - look again') })()}
/>
)}
>
)
}
/** In play: just right of the stage's navigator (its restart and frame arrows), where the counter sits. */
function playNavRight(): number {
const r = document.querySelector('.sh-play-nav')?.getBoundingClientRect()
return r ? Math.round(r.right + 8) : 176
}
/** What the microphone question shows now: the question, the download, its progress. */
function voiceAsk(v: ReturnType) {
const d = v.download
const step = v.choice === null ? 'ask' as const
// the engine could not start: why, and a way to try again - never a download that would not help
: v.server === 'unavailable' ? 'unavailable' as const
: d?.state === 'downloading' ? 'downloading' as const : d?.state === 'checking' ? 'checking' as const
: d?.state === 'failed' ? 'failed' as const : 'download' as const
return {
step, mb: v.mb, progress: d && d.total ? d.received / d.total : 0, error: v.server === 'unavailable' ? v.reason : d?.error,
onYes: () => v.setChoice('yes'), onNo: () => v.setChoice('no'), onDownload: () => void v.startDownload(),
}
}
/** The caption's words: the newest phrase in ink, a little of what came before it dim. */
function captionOf(heard: Heard[] | undefined): { old: string; live: string } | undefined {
if (!heard?.length) return undefined
const text = heard.map((w) => w.w)
const live = text.slice(-12)
const old = text.slice(Math.max(0, text.length - 24), Math.max(0, text.length - 12))
return { old: old.join(' '), live: live.join(' ') }
}
/** Open a card's thread with the caret in its reply field - to add to the conversation. */
function replyIn(c: JamCard) {
if (!c.thread) return
openThread(c)
// once the thread's card has drawn (on another board, once that board has loaded)
const until = performance.now() + 3_000
const focus = () => {
const el = useComments.getState().active === c.thread ? document.querySelector('.cm-card textarea') : null
if (el) { el.focus(); el.setSelectionRange(el.value.length, el.value.length) } else if (performance.now() < until) requestAnimationFrame(focus)
}
requestAnimationFrame(focus)
}
/** A tile's right-click menu: what can be done with this capture now. */
function tileMenu(c: JamCard): MenuItem[] {
const q = useJamQueue.getState()
const act = (a: Parameters[1], body?: string) => { const fresh = useJamQueue.getState().cards?.find((x) => x.id === c.id); if (fresh) void useJamQueue.getState().act(fresh, a, body) }
const item = (label: string, icon: ReactNode, onClick: () => void): MenuItem => ({ label, icon, onClick })
// one icon per action, never two alike in a menu: open (eye), review (checklist), resume (play), rename
// (pencil), add to queue (send arrow), keep (notebook), do next (to the top), retry, reply, thread, drop
const open = item(c.state === 'review' ? 'Review' : c.state === 'draft' ? 'Open read-back' : 'Open', c.state === 'review' ? : , () => openTile(c.id))
const rename: MenuItem = { label: 'Rename', icon: , panel: (close) => }
const reply = c.thread ? [item('Reply in thread', , () => replyIn(c))] : []
const thread = c.thread ? [item('Open thread', , () => openThread(c))] : []
switch (c.state) {
case 'draft': return [open, item('Resume capture', , () => void resumeDraft(c.id)), rename,
item('Add to queue', , () => void decideDraft(c.id, 'confirm')), item('Keep, do not send', , () => void decideDraft(c.id, 'keep')),
item('Drop', , () => void decideDraft(c.id, 'drop'))]
case 'queued': return c.kind === 'capture'
? [open, rename, item('Do next', , () => act('first')), item('Keep, do not send', , () => act('keep')), item('Drop', , () => act('drop'))]
: [open, item('Drop', , () => act('drop'))] // a queued record: open or drop it
case 'review': return [open, ...reply, ...thread, rename]
case 'asks': return [...reply, open, rename]
case 'failed': return [item('Retry', , () => act('retry')), open, ...thread, item('Drop', , () => act('drop'))]
default: return [open, ...reply, ...thread, rename]
}
}
/** Rename, in place of the menu: the title as it is, ⏎ saves, Esc leaves; empty gives back its first words. */
function RenameField({ card, close }: { card: JamCard; close: () => void }) {
const [text, setText] = useState(card.named ? card.title ?? '' : card.title === 'Capture' ? '' : card.title ?? '')
const ref = useRef(null)
useEffect(() => { requestAnimationFrame(() => ref.current?.select()) }, [])
const save = async () => {
const fresh = useJamQueue.getState().cards?.find((x) => x.id === card.id)
if (fresh && !(await useJamQueue.getState().act(fresh, 'rename', text))) useStore.getState().toast('the capture could not be renamed - look again')
close()
}
return (
setText(e.target.value)}
onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') void save(); if (e.key === 'Escape') close() }} />
)
}
/** The agent's reply to a card, as the thread shows it. */
function replyText(c: JamCard): string {
const threads = useComments.getState().threads
const t = threads.find((x) => x.id === c.thread)
return t?.replies.find((r) => r.id === c.reply)?.body ?? ''
}
/** Lift the bottom edge while the lane shows: the sidebar stops above it, the Update pill, toasts and
* notices rise one lane. Returns the class for `.sh-app` - in its className, so a re-render of the app
* (interact on or off, the theme) never drops it. */
export function useLaneLift(): string {
return useLaneShown() ? ' jm-lane-on' : ''
}