/** * Jam V2's panels (spec 27 Parts 1-3): the focus panel the review and the capture detail open in, the * read-back, the review detail, the notes card, how a capture's comment reads in its thread, and the * capture's marks on the canvas (the teal edge ring, numbered points). * * Presentational only: every component takes what it shows and calls back what was chosen. The shell * (jam-capture.ts, App) owns state, keys and the API. */ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { JamStateIcon } from './jam-ui.tsx' import type { TimelineLine } from './jam-lines.ts' import { speechLines } from '../../shared/speech.ts' import { ArrowBendUpLeftIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, CopyIcon, CursorClickIcon, CursorIcon, EyeIcon, FileCodeIcon, FrameRectIcon, ImagesSquareIcon, LaserIcon, MicrophoneIcon, MoonIcon, PencilSimpleIcon, SignpostIcon, SunIcon, TextboxIcon, ParallelogramFillIcon, WarningIcon, WaveformIcon, XIcon, deviceIcon, } from './icons.tsx' import { LANE_H } from './jam-lane.tsx' import { Tip } from './Tip.tsx' import { useJamQueue } from './jam-queue.ts' import { holdsReload } from './api.ts' import { unsavedWhen, workChanged } from './unsaved.ts' /** A heading cut short by its ellipsis: its tooltip shows it whole (one that fits shows none). */ const cutShort = (el: HTMLElement) => el.scrollWidth > el.clientWidth + 1 /** Marver's mark, as the thread draws it (Comments.tsx MarverAvatar) - drawn here from the same classes * so the panels never import the comments module (which will import these panels). */ export const Marver = ({ size = 20 }: { size?: number }) => ( ) // ---- the focus panel --------------------------------------------------------------------------------- /** The one place a detail opens (v9): its top level with the sidebar's and the toolbar's tops, its * bottom just above the lane, its left edge at `left` (the sidebar's right edge plus an inset - the * shell passes it), `width` px wide (520 at least). The panel inside fills it; its middle scrolls and * its footer stays in view. `onClose` adds a quiet close button in its corner. */ export function FocusPanel({ children, left, width = 580, onClose }: { children: ReactNode; left: number; width?: number; onClose?: () => void }) { // the window's width is the last word: follow it as it changes const [, setTick] = useState(0) useEffect(() => { const on = () => setTick((n) => n + 1); window.addEventListener('resize', on); return () => window.removeEventListener('resize', on) }, []) // 580: the footer's "For Claude Code" and its four actions on one line; never wider than the window allows // the window is the last word: a narrow one gets a narrower panel, never one past its edge const room = (typeof window !== 'undefined' ? window.innerWidth : 1400) - left - 12 const w = Math.min(room, Math.max(360, Math.max(580, width))) return (
{children} {onClose && }
) } // ---- the timeline -------------------------------------------------------------------------------------- /** More work words not sent yet, by card - they survive the panel closing on a click elsewhere. */ const moreDrafts = new Map() export const clearMoreDraft = (id: string) => { moreDrafts.delete(id); workChanged() } export const moreDraftOf = (id: string): string => moreDrafts.get(id) ?? '' export const setMoreDraft = (id: string, t: string) => { if (t) moreDrafts.set(id, t); else moreDrafts.delete(id); workChanged() } // spec 28 A1.11: a page whose canvas came back reloads only once no such words wait (a reload would drop them) holdsReload(() => moreDrafts.size > 0) // and leaving the page asks first (spec 28 A2.9) unsavedWhen('typing', () => moreDrafts.size > 0) const unquote = (s: string) => s.replace(/^"(.*)"$/s, '$1') const DEVICE: Record = { phone: 'mobile', mobile: 'mobile', tablet: 'tablet', laptop: 'laptop', desktop: 'monitor', monitor: 'monitor', tv: 'tv' } /** One line: time | glyph | what happened (the read-back's merged timeline, jam-lines.ts). */ function LineRow({ l }: { l: TimelineLine }) { const ui = (s: string) => {s} const code = l.source ? {l.source.split('/').pop()} : null let glyph: ReactNode = null, body: ReactNode = l.text switch (l.kind) { case 'words': glyph = ; body = “{unquote(l.text)}”; break case 'note': glyph = ; body = {unquote(l.text)}; break case 'view': glyph = ; break case 'pick': glyph = ; body = <>{l.point != null && {l.point}}{l.text}{code}; break case 'click': glyph = ; body = <>{l.point != null && {l.point}}{l.text}{code}; break case 'hover': glyph = ; body = <>{l.text}{code}; break case 'field': glyph = ; body = <>{l.text}{code}; break case 'screen': glyph = ; break case 'scroll': glyph = ; break case 'select': glyph = ; body = <>selected {ui(l.text)}; break case 'device': { const name = (l.text.split(' · ')[1] ?? '').split(' ')[0].toLowerCase() glyph = deviceIcon(DEVICE[name] ?? null, 13); body = <>viewed at {ui(l.text.split(' · ').slice(1).join(' · ') || l.text)}; break } case 'theme': glyph = l.text === 'dark' ? : ; body = <>switched to {ui(l.text)}; break case 'copied': { const [what, of] = l.text.split(' of ') glyph = what === 'path' ? : ; body = <>copied the {what} of {ui(of ?? '')}; break } case 'interact': glyph = ; body = <>interactive mode {ui(l.text)}; break case 'picture': glyph = ; body = <>picture {l.point} · {l.text}; break case 'missing': glyph = ; body = {l.text}; break } return
{l.t}{glyph}{body}
} /** The merged timeline: words and notes among every step, each at its moment. */ export const Lines = ({ lines }: { lines: TimelineLine[] }) =>
{lines.map((l, i) => )}
const newNoteId = () => `n-${Math.random().toString(36).slice(2, 12).padEnd(8, '0')}` /** Notes written in a read-back after the capture ended: under a divider, in the timeline's own rows, then a * clean field and Add note (⏎). What you type is never a capture key. */ function AddedNotes({ added, onAdd }: { added: { at: number; text: string }[]; onAdd?: (text: string, id: string) => Promise }) { const [text, setText] = useState('') const [busy, setBusy] = useState(false) // the note in the field: one id until it saves (a failed one sent again is the same note) const nid = useRef(newNoteId()) const add = async () => { const t = text.trim() if (!t || busy || !onAdd) return setBusy(true) const ok = await onAdd(t, nid.current) setBusy(false) if (ok) { setText(''); nid.current = newNoteId() } } return (
{added.length > 0 && <>
Added after
{added.map((a, i) => (
{new Date(a.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{a.text}
))}
} {onAdd && (
{/* read-only while it saves (never disabled: a disabled field lets its focus go, and the next ⏎ would reach the read-back's own keys) */} setText(e.target.value)} onKeyDown={(e) => { e.stopPropagation() if ((e.nativeEvent as unknown as { isComposing?: boolean }).isComposing) return if (e.key === 'Enter') { e.preventDefault(); void add() } else if (e.key === 'Escape') { e.preventDefault(); setText(''); (e.target as HTMLInputElement).blur() } }} />
)}
) } /** A picture, or the dashed "No picture" where a shot failed. */ export function Shot({ url, cap, alt, onSize }: { url?: string; cap?: ReactNode; alt?: string; onSize?: (w: number, h: number) => void }) { // a picture that cannot load (gone, refused) is "No picture" too - never a broken image const [failed, setFailed] = useState(null) const ok = !!url && failed !== url return (
{ok ?
{alt setFailed(url!)} onLoad={onSize ? (e) => onSize(e.currentTarget.naturalWidth, e.currentTarget.naturalHeight) : undefined} />
:
No picture
} {cap && {cap}}
) } /** "1 of 3" with its arrows - the walk's ends disable the arrow that has nowhere to go. */ const Walk = ({ of, onPrev, onNext }: { of?: [number, number]; onPrev?: () => void; onNext?: () => void }) => of ? ( <> {of[0]} of {of[1]} ) : null const Meta = ({ items }: { items: ReactNode[] }) => (
{items.map((m, i) => {i > 0 && }{m})}
) // ---- the read-back -------------------------------------------------------------------------------------- /** * A capture's detail (the read-back of a draft; read-only once confirmed). Head: its first words and * "1 of N". Then a line of meta (length, frames, points, pictures, notes, steps), the pictures large * (two to a row), and the merged timeline. Click the timeline to edit the words (a field; saved on blur * through `onEdit`). A draft's footer names the agent: Drop (esc), Keep (K), Add to queue (⏎). Read-only * shows Open thread instead. * * `edited` = your edit so far (the field opens on it; else on the words of `lines`); `editing` opens * the field from outside (it is also opened by a click); `follows` = the reviewed card's title for a * capture made during a review; `err` = a confirm that failed closed; `warn` = degraded content * ("words may be incomplete"). */ export function ReadBack({ title, head, of, onPrev, onNext, meta, lines, pictures, edited, pending, editing, onEdit, onRetry, onConfirm, onKeep, onDrop, onResume, agent, follows, readOnly, locked, onOpenThread, added, onAddNote, autoNamed, err, warn, style }: { title: string /** beside the title: the Review | Capture switch (a capture under review) */ head?: ReactNode of?: [number, number] onPrev?: () => void onNext?: () => void meta: ReactNode[] lines: TimelineLine[] pictures: { id: string; url: string; caption: ReactNode }[] edited?: string /** words written that have not saved yet - they win over `edited` */ pending?: string /** a failed capture: its Retry (with Drop beside it) in the read-only footer */ onRetry?: () => void editing?: boolean onEdit?: (text: string) => void onConfirm?: () => void onKeep?: () => void onDrop?: () => void /** ended too soon: recording goes on where it stopped (R) */ onResume?: () => void agent?: string | null follows?: string readOnly?: boolean /** the words cannot be edited for now (its Resume is starting) - the rest stays as it is */ locked?: boolean onOpenThread?: () => void /** notes written here after it ended - under a divider, as the timeline shows its own */ added?: { at: number; text: string }[] /** the title came from Claude Haiku (jam.names): its tooltip says so, and where the words went */ autoNamed?: boolean /** a note added from here (a draft): resolves whether it saved */ onAddNote?: (text: string, id: string) => Promise err?: ReactNode warn?: ReactNode style?: CSSProperties }) { const [open, setOpen] = useState(!!editing) useEffect(() => { if (editing) setOpen(true) }, [editing]) // the words and the notes, in time order - what the capture says (server captureWords) const words = speechLines(lines.flatMap((l) => (l.kind === 'words' || l.kind === 'note' ? [{ kind: l.kind, text: unquote(l.text) }] : []))) const [text, setText] = useState(pending ?? edited ?? words) // the field follows the saved edit only while you are not writing and nothing newer waits to save // (`pending`): an older save's answer never overwrites newer words, and a failed one never loses them useEffect(() => { if (!open) setText(pending ?? edited ?? words) }, [edited, words, pending]) const field = useRef(null) useEffect(() => { if (open) { const el = field.current; if (el) { el.focus(); el.setSelectionRange(el.value.length, el.value.length) } } }, [open]) // compared with what is meant now - a pending save included: going back to the saved words while a // newer save is out is a save of its own, queued after it const lastSent = useRef(undefined) const save = () => { setOpen(false) if (text !== (lastSent.current ?? pending ?? edited ?? words)) { lastSent.current = text; onEdit?.(text) } } // once the saved edit is what was last sent, the intent is settled useEffect(() => { if (lastSent.current !== undefined && edited === lastSent.current && pending === undefined) lastSent.current = undefined }, [edited, pending]) return (
{title}{autoNamed && <>
Named by Claude Haiku from its words, sent to Anthropic through Claude Code - jam.names: false turns it off}} only={autoNamed ? undefined : cutShort}>

{title || 'Capture'}

{head}
{follows &&
Follows {follows} - confirming sends that review back as more work
} {err &&
{err}
} {warn &&
{warn}
} {pictures.length > 0 &&
{pictures.map((p) => )}
} {open && !readOnly && !locked ? (