/** * The Jam session in the shell (spec 27, Part 5): `J` (or the Jam button) opens the start sheet; Start * begins the session - the microphone (if you said yes) hears its talk between captures; `J` again ends * it with the wrap-up sheet: every capture of the session with where it stands, your instructions to the * agent, whether to include what was said between captures - "Write the record" queues the record job, * "End without a record" just ends it. The record comes back as the run's last tile, reviewed in the focus * panel like a capture. * * The Jam button (between Laser and Tidy): the waveform at rest; the session's minutes while one runs (a * crossed microphone without one); a teal dot only while a capture records. Blue only on hover and press. */ import { useEffect, useRef, useState, type CSSProperties } from 'react' import { setSessionEnding, useJamCapture } from './jam-capture.ts' import { laneCards, startCapture } from './jam-keys.ts' import { useJamQueue, type JamCard } from './jam-queue.ts' import { useJamUi } from './jam-state.ts' import { JamStateIcon } from './jam-ui.tsx' import { Marver, moreDraftOf, setMoreDraft } from './jam-panel.tsx' import { useJamVoice, voiceSession, voiceSessionEnd, voiceSessionId } from './jam-voice.ts' import { HARNESS } from './Comments.tsx' import { ArrowRightIcon, CaretIcon, KeyboardIcon, MicrophoneIcon, MicrophoneSlashIcon, WarningIcon, WaveformIcon } from './icons.tsx' import { Tip } from './Tip.tsx' import { useSessionClock } from './jam-lane.tsx' import { CONFIG, stoppedFor, useStore } from './store.ts' import { api, STOPPED_TIP, useStopped } from './api.ts' const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? '' const now = () => performance.timeOrigin + performance.now() // ---- the session's moves ------------------------------------------------------------------------------- /** Why the last session move was refused (the server's words), for the one line that says so. */ let lastError: string | undefined async function post(body: Record, onAnswer?: (ok: boolean) => void): Promise { const r = await api('jam/session', { method: 'POST', headers: { 'content-type': 'application/json', 'x-mv-c': csrf() }, body: JSON.stringify(body) }).catch(() => null) lastError = r && !r.ok ? ((await r.json().catch(() => null)) as { error?: string } | null)?.error : r ? undefined : 'marver dev did not answer' // what the server's answer settles happens at once, never behind the refresh (which may be slow, and a push // may come first) try { onAnswer?.(!!r?.ok) } catch { /* the refresh still runs */ } await useJamQueue.getState().load() return !!r?.ok } /** Start a session: the dev server holds it (it survives a reload); the microphone hears its talk. */ export async function startSession(): Promise { if (stoppedFor('Jam needs the canvas running')) return false // spec 28 A1.11 const id = `ses-${Date.now()}-${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}` // this tab holds the session's microphone (a reload of it takes it back; other tabs never take it) - from // the moment the server has it: the push of it may come before the refresh // the talk starts once the queue shows the session - whichever comes first: the push (before the answer, // or after it), or the refresh; a session ended meanwhile never gets the microphone const hear = () => { if (useJamQueue.getState().session?.id === id && voiceSessionId() !== id) void voiceSession(id, now()) } const ok = await post({ action: 'start', id }, (ok) => { if (!ok) return try { sessionStorage.setItem(MIC_OWNER, id) } catch { /* this page only */ } useJamUi.getState().setSheet(null) // the session starts capturing at once - its first capture is under way as soon as it starts (its talk // begins when that capture ends) // (over an open review it follows that review, as S does) if (!useJamCapture.getState().current) startCapture(id) hear() }) if (!ok) { useStore.getState().toast('the session could not start - look again'); return false } useJamUi.getState().setSheet(null) hear() return true } /** The session whose microphone this tab holds (per tab: it survives a reload, not another tab). */ export const MIC_OWNER = 'mv-jam-session-mic' export const holdsSessionMic = (id: string): boolean => { try { return sessionStorage.getItem(MIC_OWNER) === id } catch { return false } } /** End the session - with a record (the wrap-up's instructions and talk switch), or without one. */ export async function endSession(write: { instructions: string; includeTalk: boolean } | null, onAnswer?: () => void): Promise { const ses = useJamQueue.getState().session if (!ses) { useJamUi.getState().setSheet(null); return true } // never over a capture still recording, sealing or sending: it belongs in the session (and its record) const cap = useJamCapture.getState() if (cap.current || Object.keys(cap.caps).length || cap.unsent.length) { useStore.getState().toast(cap.current ? 'a capture is recording - end it first (E), then wrap up' : 'a capture is still being sent - a moment, then wrap up') return false } // from here until the server answers, no capture starts (the record must not miss it); the server also // refuses the record while a capture of the session is open in any tab setSessionEnding(true) // each try is its own: an older one finishing late (its refresh slow) never frees what a newer one holds const op = ++endingOp const mine = () => op === endingOp // the server's answer, acted on at once (not behind the refresh): captures may start again; refused, the // session goes on - its talk heard again in the tab that holds its microphone, and you are told why const answered = (ok: boolean) => { if (!mine()) return // ended: gone from this page at once - a capture started from here on is not the session's if (ok) useJamQueue.getState().forget(ses.id) setSessionEnding(false) onAnswer?.() if (ok) { useJamUi.getState().setSheet(null); return } if (useJamQueue.getState().session?.id === ses.id && holdsSessionMic(ses.id) && voiceSessionId() !== ses.id) void voiceSession(ses.id, now()) useStore.getState().toast(write ? `the record could not be written - ${lastError ?? 'look again'}` : 'the session could not end - look again') } try { // the talk's last words first: they belong in the transcript the record reads await voiceSessionEnd() return write ? await post({ action: 'wrapup', id: ses.id, instructions: write.instructions, includeTalk: write.includeTalk, board: useStore.getState().board }, answered) : await post({ action: 'end', id: ses.id }, answered) } finally { if (mine()) setSessionEnding(false) } } /** The latest try at ending the session (each Write or End is one). */ let endingOp = 0 /** `J` (or the Jam button): at rest, the start sheet (again: closes it); in a session, the wrap-up - a * capture recording ends first, and the wrap-up waits for its read-back to close. */ export function pressJ() { const ui = useJamUi.getState() // the canvas stopped (spec 28 A1.11): a sheet open still closes; nothing new starts or wraps up if (!ui.sheet && stoppedFor('Jam needs the canvas running')) return if (!useJamQueue.getState().session) { ui.setSheet(ui.sheet === 'start' ? null : 'start'); return } if (ui.sheet === 'wrap' || ui.sheet === 'wrap-pending') { ui.setSheet(null); return } if (useJamCapture.getState().current) { useJamCapture.getState().end(); ui.setSheet('wrap-pending'); return } ui.setSheet('wrap') } // ---- the Jam button ---------------------------------------------------------------------------------------- /** The session's length in minutes, ticking. */ export function JamButton({ inPlay }: { inPlay?: boolean }) { const session = useJamQueue((s) => s.session) const recording = useJamCapture((s) => !!s.current) const sheet = useJamUi((s) => s.sheet) const voice = useJamVoice() const minutes = useSessionClock(session?.since) // off: no, blocked, no engine - or stopped (another tab has it, the connection went) while you said yes const micOff = voice.choice === 'no' || voice.denied || voice.server === 'unavailable' || (!!session && voice.choice === 'yes' && !voice.live && !recording) const state = recording ? 'rec' : session ? 'live' : 'idle' // the canvas stopped (spec 28 A1.11): quiet, saying why - a capture already recording goes on const stopped = useStopped() const label = stopped ?
Jam needs the canvas running{STOPPED_TIP}
: session ? <>End the sessionJ : <>Jam sessionJ return ( ) } // ---- the start sheet ----------------------------------------------------------------------------------------- /** Jam's keys, one list: the shortcuts tooltip (the key table in jam-keys.ts is the source of truth). */ export const JAM_KEYS: [string, string][] = [ ['Start a capture', 'S'], ['End it', 'E'], ['Drop it', 'esc'], ['Add a note', 'N'], ['Picture of the frame', 'I'], ['Resume a capture', 'R'], ['The captures', 'Q'], ['Open a capture', '⏎'], ['Keep it, not sent', 'K'], ['Review the next', 'V'], ['End the session', 'J'], ] /** "Keyboard shortcuts": one row; hovering or focusing it shows every Jam key in one column, action then * key, in the inverse theme. */ function ShortcutsRow() { const [on, setOn] = useState(false) return (
setOn(true)} onMouseLeave={() => setOn(false)} onFocus={() => setOn(true)} onBlur={() => setOn(false)}> Keyboard shortcutsView {on && (
Jam keys
{JAM_KEYS.map(([a, k]) =>
{a}{k}
)}
)}
) } export function StartSheet({ style }: { style?: CSSProperties }) { const agent = useJamQueue((s) => s.agent) const voice = useJamVoice() const mic = voice.choice === 'yes' && voice.server !== 'unavailable' && !voice.denied const name = agent ? HARNESS[agent] ?? agent : null return (

Jam session

Start captures at once. Between captures the session stays on; S captures again, J wraps up.

{name ?
Hands work to{name}
:
No coding agent found - work waits in the queue.
npx marver init sets one up.
}
) } // ---- the wrap-up ---------------------------------------------------------------------------------------------- /** The open wrap-up sheet's Write the record (the key row calls it). */ export const wrapWrite: { current: (() => void) | null } = { current: null } const STATE_NOTE: Partial> = { draft: 'to confirm', confirming: 'sending', queued: 'queued', working: 'with the agent', asks: 'asks you', review: 'to review', done: 'done', kept: 'kept - for the record', failed: 'failed', } export function WrapSheet({ style }: { style?: CSSProperties }) { const session = useJamQueue((s) => s.session) const cards = useJamQueue((s) => s.cards) const run = useJamQueue((s) => s.run) const agent = useJamQueue((s) => s.agent) const [text, setText] = useState('') const [talk, setTalk] = useState(true) const [talkMin, setTalkMin] = useState(0) const [tags, setTags] = useState(0) const [folder, setFolder] = useState(null) const [busy, setBusy] = useState(false) const field = useRef(null) useEffect(() => { api('jam/session', { headers: { 'x-mv-c': csrf() } }).then((r) => (r.ok ? r.json() : null)).then((b) => { setTalkMin(b?.talkMinutes ?? 0); setTags(b?.tags ?? 0); setFolder(typeof b?.folder === 'string' ? b.folder : null) }, () => {}) requestAnimationFrame(() => field.current?.focus()) }, []) // the field grows with its words: three lines at least useEffect(() => { const el = field.current; if (el) { el.style.height = 'auto'; el.style.height = `${Math.max(el.scrollHeight, 66)}px` } }, [text]) useEffect(() => () => { wrapWrite.current = null }, []) const tries = useRef(0) // (a hook: above the early return) if (!session) return null const minutes = Math.max(1, Math.round((Date.now() - session.since) / 60_000)) // the session's captures - the lane's tiles - with where each stands const caps = laneCards(cards, run).filter((c) => c.kind === 'capture' && c.session === session.id && c.state !== 'dropped') const name = agent ? HARNESS[agent] ?? agent : 'your agent' // Write is busy until the server answers THIS try (an older try's late end never frees it) const write = async () => { if (busy) return const n = ++tries.current setBusy(true) await endSession({ instructions: text.trim(), includeTalk: talk }, () => { if (n === tries.current) setBusy(false) }) if (n === tries.current) setBusy(false) } // ⏎ anywhere in the sheet writes the record - it never reaches a panel beneath it (jam-keys' sheet row) wrapWrite.current = () => void write() const summary = caps.length ? `${caps.length} capture${caps.length === 1 ? '' : 's'} in ${minutes} min.` : `No captures in ${minutes} min.` return (

Wrap up the session

{minutes} min

{summary}{tags ? ` ${tags} tag${tags === 1 ? ' was' : 's were'} answered in ${tags === 1 ? 'its thread' : 'their threads'}.` : ''}

{caps.length > 0 && (
{caps.map((c) => (
{c.title ?? 'Capture'}{STATE_NOTE[c.state] ?? c.state}
))}
)}
For {name}