Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from 'react'; | |
| import type { CSSProperties, ReactNode } from 'react'; | |
| import * as api from '../api'; | |
| import type { MetaSession } from '../api'; | |
| import type { Cli, OverviewFilter, Session, SessionState, Tree } from '../types'; | |
| import { renderMarkdown } from '../lib/markdown'; | |
| import Logo from './Logo'; | |
| const fmtAgo = (ts: number) => { | |
| if (!ts) return ''; | |
| const m = Math.round((Date.now() - ts) / 60000); | |
| if (m < 1) return 'now'; | |
| if (m < 60) return `${m}m`; | |
| if (m < 48 * 60) return `${Math.round(m / 60)}h`; | |
| return `${Math.round(m / 1440)}d`; | |
| }; | |
| const fmtTok = (n = 0) => | |
| n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n); | |
| const base = (p: string) => p.split('/').pop() || p; | |
| const eligible = (s: Session) => s.cli !== 'shell' && s.cli !== 'files'; | |
| const bucket = (state: SessionState): OverviewFilter => | |
| state === 'working' ? 'working' : state === 'waiting' ? 'waiting' : 'quiet'; | |
| const Caret = () => ( | |
| <svg className="ov-caret" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 3.2h6.4L5 7.4z" fill="currentColor" /></svg> | |
| ); | |
| function Card({ s, color, pending, onOpen, onClose }: { | |
| s: MetaSession; | |
| color?: string; | |
| pending?: boolean; // digest still loading — show a shimmer instead of "no prompt yet" | |
| onOpen: (sid: string) => void; | |
| onClose?: () => void; // present when the card lives in the conversation window | |
| }) { | |
| const d = s.digest; | |
| const [draft, setDraft] = useState(''); | |
| const [sending, setSending] = useState(false); | |
| const [failed, setFailed] = useState(false); | |
| // Optimistic echo: the sent text becomes the prompt line the moment the | |
| // send succeeds — the digest round-trip (CLI writes transcript → rebuild → | |
| // poll) can take seconds, and a frozen card reads as "did that get lost?". | |
| const [sent, setSent] = useState<{ text: string; at: number } | null>(null); | |
| const [expanded, setExpanded] = useState(false); | |
| const [promptOpen, setPromptOpen] = useState(false); // click the prompt to read it fully | |
| const [histIdx, setHistIdx] = useState(0); // 0 = live view, n = n-th exchange back | |
| const inputRef = useRef<HTMLTextAreaElement>(null); | |
| // After you send (or when the transcript shows a prompt newer than the last | |
| // answer), the old answer is stale — a spinner takes its place. | |
| const digestCaughtUp = !!d && !!sent && d.lastPromptTs >= sent.at - 60_000; | |
| if (sent && digestCaughtUp) setSent(null); | |
| // Running: the agent's own task lifecycle when the transcript provides one | |
| // (codex task_started/complete), else the terminal-derived state. | |
| const running = !!d?.running || s.state === 'working'; | |
| const awaiting = (!!sent && !digestCaughtUp) || (!!d && !!d.lastPromptText && d.lastPromptTs > d.lastAssistantTs && running); | |
| const hist = d?.turnsLog ?? []; | |
| const idx = Math.min(histIdx, hist.length); | |
| const entry = idx > 0 ? hist[idx - 1] : null; | |
| const send = async () => { | |
| const text = draft.trim(); | |
| if (!text || sending) return; | |
| setSending(true); | |
| setFailed(false); | |
| try { | |
| await api.sendInput(s.id, text); | |
| setDraft(''); | |
| setSent({ text, at: Date.now() }); | |
| setHistIdx(0); | |
| setPromptOpen(false); | |
| if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); } | |
| } catch { | |
| setFailed(true); | |
| setTimeout(() => setFailed(false), 4000); | |
| } | |
| setSending(false); | |
| }; | |
| const ago = fmtAgo(Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0); | |
| const promptText = sent ? sent.text : d?.lastPromptText || ''; | |
| const answerText = entry ? entry.answer : d?.lastAssistantText || ''; | |
| const answerMd = entry ? entry.answerMd : d?.lastAssistantMd || ''; | |
| // Chronological position: hist is newest-first, live text is the newest turn. | |
| const totalTurns = hist.length + (d?.lastAssistantText ? 1 : 0); | |
| const metaBits: string[] = []; | |
| if (d && (d.sinceTurns || d.sinceToolCalls)) { | |
| metaBits.push(`${d.sinceTurns} turn${d.sinceTurns === 1 ? '' : 's'}`, `${d.sinceToolCalls} tool${d.sinceToolCalls === 1 ? '' : 's'}`); | |
| if (d.sinceFiles.length) metaBits.push(d.sinceFiles.map(base).join(', ')); | |
| if (d.sinceTokens > 0) metaBits.push(`${fmtTok(d.sinceTokens)} tok`); | |
| } | |
| const showLiveProgress = !entry && (running || awaiting); | |
| return ( | |
| <div className="ov-card"> | |
| <div className="ov-id" onClick={() => onOpen(s.id)} title="Open pane"> | |
| <span className={`status ${s.state}`} /> | |
| <Logo cli={s.cli} size={12} tint={color} /> | |
| <span className="ov-name mono">{s.name}</span> | |
| {ago && <span className="ov-ago">· {ago}</span>} | |
| <span className="spacer" /> | |
| <span className="ov-go">open ↗</span> | |
| {onClose && <button className="ov-x" onClick={(e) => { e.stopPropagation(); onClose(); }} title="Close">✕</button>} | |
| </div> | |
| {promptText ? ( | |
| <div | |
| className={`ov-prompt${promptOpen ? ' open' : ''}`} | |
| title={promptOpen ? 'Collapse' : 'Show the full prompt'} | |
| onClick={() => setPromptOpen((v) => !v)} | |
| >{promptOpen ? (sent ? sent.text : (d?.lastPromptRaw || promptText)) : promptText}</div> | |
| ) : pending ? ( | |
| <div className="ov-prompt-skel"><span className="skel" style={{ width: '70%' }} /></div> | |
| ) : ( | |
| <div className="ov-prompt ov-prompt-none">no prompt yet</div> | |
| )} | |
| {(metaBits.length > 0 || hist.length > 0) && ( | |
| <div className="ov-meta mono"> | |
| <span className="ov-meta-bits">{metaBits.join(' · ')}</span> | |
| <span className="spacer" /> | |
| {hist.length > 0 && ( | |
| <span className="ov-nav"> | |
| {idx > 0 && <span className="ov-nav-pos">turn {totalTurns - idx}/{totalTurns}</span>} | |
| <button | |
| className="ov-nav-btn" title="Earlier turn" disabled={idx >= hist.length} | |
| onClick={() => { setHistIdx(Math.min(idx + 1, hist.length)); setExpanded(false); }} | |
| >↑</button> | |
| <button | |
| className="ov-nav-btn" title="Later turn" disabled={idx === 0} | |
| onClick={() => { setHistIdx(Math.max(idx - 1, 0)); setExpanded(false); }} | |
| >↓</button> | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| {showLiveProgress ? ( | |
| <div className="ov-answer-wrap"> | |
| {answerText && d && d.lastAssistantTs >= d.lastPromptTs && ( | |
| <div className="ov-answer ov-answer-dim">{answerText}</div> | |
| )} | |
| <div className="ov-busy mono">running</div> | |
| </div> | |
| ) : answerText ? ( | |
| <div className="ov-answer-wrap"> | |
| {expanded ? ( | |
| <div className="markdown ov-md" dangerouslySetInnerHTML={{ __html: renderMarkdown(answerMd || answerText) }} /> | |
| ) : ( | |
| <div className="ov-answer">{answerText}</div> | |
| )} | |
| <button className="ov-more" onClick={() => setExpanded((e) => !e)}>{expanded ? 'less' : 'more'}</button> | |
| </div> | |
| ) : null} | |
| <div className="ov-live"> | |
| <span className="ov-p mono">❯</span> | |
| <textarea | |
| ref={inputRef} | |
| rows={1} | |
| value={draft} | |
| disabled={sending} | |
| placeholder={sending ? 'sending…' : 'reply…'} | |
| autoComplete="off" | |
| autoCorrect="off" | |
| autoCapitalize="off" | |
| spellCheck={false} | |
| onChange={(e) => { setDraft(e.target.value); e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`; }} | |
| // iOS doesn't resize the layout for the keyboard — scroll the | |
| // input into view once the keyboard has animated in. | |
| onFocus={(e) => { const el = e.currentTarget; setTimeout(() => el.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300); }} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } | |
| if (e.key === 'Escape') { setDraft(''); inputRef.current?.blur(); } | |
| }} | |
| /> | |
| {draft.trim() && <span className="ov-hint">↵ send · ⇧↵ newline</span>} | |
| </div> | |
| {failed && <div className="ov-note">failed to reach the agent</div>} | |
| </div> | |
| ); | |
| } | |
| /** Compact tile: status + prompt + state; click opens the conversation window. */ | |
| function Tile({ s, color, dim, pending, onOpen }: { s: MetaSession; color?: string; dim?: boolean; pending?: boolean; onOpen: () => void }) { | |
| const d = s.digest; | |
| const running = !!d?.running || s.state === 'working'; | |
| const last = Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0; | |
| // ring = waiting on you AND recent — a fleet where everything is "waiting | |
| // since last week" shouldn't glow everywhere | |
| const fresh = s.state === 'waiting' && Date.now() - last < 24 * 3600e3; | |
| return ( | |
| <div className={`ovt-tile${fresh ? ' attn' : ''}${dim ? ' archived' : ''}`} onClick={onOpen}> | |
| <div className="ovt-head"> | |
| <span className={`status ${s.state}`} /> | |
| <Logo cli={s.cli} size={12} tint={color} /> | |
| <span className="ovt-name mono">{s.name}</span> | |
| <span className="ovt-ago">{pending ? '' : fmtAgo(last)}</span> | |
| </div> | |
| {pending ? ( | |
| <> | |
| <span className="skel" style={{ width: '82%' }} /> | |
| <span className="skel" style={{ width: '38%', height: 7 }} /> | |
| </> | |
| ) : ( | |
| <> | |
| {d?.lastPromptText | |
| ? <div className="ovt-prompt" title={d.lastPromptText}>{d.lastPromptText}</div> | |
| : <div className="ovt-prompt none">no prompt yet</div>} | |
| {running | |
| ? <div className="ovt-state running mono">running</div> | |
| : s.state === 'stopped' | |
| ? <div className="ovt-state stopped mono">stopped</div> | |
| : d?.lastAssistantText | |
| ? <div className="ovt-state done mono">✓ done</div> | |
| : <div className="ovt-state idle mono">idle</div>} | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /** Mission control: one reading column — group capsules with their agents as | |
| * slabs, loose agents as standalone panels. */ | |
| export default function Overview({ clis, tree, filter, view, archived, showArchived, onOpen }: { | |
| clis: Cli[]; | |
| tree: Tree; | |
| filter: OverviewFilter; // controlled by the bottom bar in App | |
| view: 'tiles' | 'list'; // controlled by the bottom bar in App | |
| archived: Set<string>; | |
| showArchived: boolean; | |
| onOpen: (sid: string) => void; | |
| }) { | |
| const [meta, setMeta] = useState<Record<string, MetaSession>>({}); | |
| // Progressive load: the layout renders immediately from the tree; digests | |
| // stream in per session (newest first) until the bulk pass lands and marks | |
| // everything loaded. A tile shows a shimmer until its id is in `loaded`. | |
| const [loaded, setLoaded] = useState<Set<string>>(new Set()); | |
| const bulkDone = useRef(false); | |
| const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); | |
| const [durs, setDurs] = useState<Record<string, number>>({}); | |
| const [openId, setOpenId] = useState<string | null>(null); // conversation window | |
| useEffect(() => { | |
| if (!openId) return; | |
| const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpenId(null); }; | |
| document.addEventListener('keydown', onKey); | |
| return () => document.removeEventListener('keydown', onKey); | |
| }, [openId]); | |
| // Skip the setState when the poll returns byte-identical data — otherwise | |
| // every tick re-renders every tile (and the conversation window) for nothing. | |
| const lastPayload = useRef(''); | |
| useEffect(() => { | |
| let alive = true; | |
| const load = () => api.getMeta() | |
| .then((r) => { | |
| if (!alive) return; | |
| bulkDone.current = true; | |
| const payload = JSON.stringify(r.sessions); | |
| if (payload === lastPayload.current) return; | |
| lastPayload.current = payload; | |
| setMeta(Object.fromEntries(r.sessions.map((s) => [s.id, s]))); | |
| setLoaded(new Set(r.sessions.map((s) => s.id))); | |
| }) | |
| .catch(() => {}); | |
| load(); | |
| const t = setInterval(() => { if (!document.hidden) load(); }, 1000); | |
| return () => { alive = false; clearInterval(t); }; | |
| }, []); | |
| // Per-session digests while the bulk build is still running, top of the | |
| // feed first, a few in flight at a time. | |
| const progressive = useRef(false); | |
| useEffect(() => { | |
| if (progressive.current || bulkDone.current || !tree.sessions.length) return; | |
| progressive.current = true; | |
| let alive = true; | |
| const ids: string[] = []; | |
| for (const ref of tree.order) { | |
| if (ref.startsWith('s:')) { const s = tree.sessions.find((x) => x.id === ref.slice(2)); if (s && eligible(s)) ids.push(s.id); } | |
| else { const g = tree.groups.find((x) => x.id === ref.slice(2)); if (g) for (const sid of g.sessionIds) { const s = tree.sessions.find((x) => x.id === sid); if (s && eligible(s)) ids.push(s.id); } } | |
| } | |
| const queue = [...ids]; | |
| const worker = async () => { | |
| while (alive && !bulkDone.current && queue.length) { | |
| const id = queue.shift()!; | |
| try { | |
| const r = await api.getMetaOne(id); | |
| if (!alive || bulkDone.current) return; | |
| if (r.digest) { | |
| const s = tree.sessions.find((x) => x.id === id); | |
| if (s) setMeta((m) => ({ ...m, [id]: { ...s, digest: r.digest } })); | |
| setLoaded((l) => new Set(l).add(id)); | |
| } | |
| } catch { /* bulk will cover it */ } | |
| } | |
| }; | |
| Promise.all([worker(), worker(), worker()]).catch(() => {}); | |
| return () => { alive = false; }; | |
| }, [tree]); | |
| const colorOf = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c.color])), [clis]); | |
| const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]); | |
| const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]); | |
| const dataFor = (s: Session): MetaSession => meta[s.id] ?? { ...s, digest: null }; | |
| const pending = (id: string) => !loaded.has(id); // digest not in yet — shimmer | |
| const visible = (s: MetaSession) => | |
| (filter === 'all' || bucket(s.state) === filter) && (showArchived || !archived.has(s.id)); | |
| // Collapse at constant velocity: duration follows the group's height. | |
| const toggleGroup = (gid: string, el: HTMLElement) => { | |
| const inner = el.closest('.ov-sec')?.querySelector('.ov-drawer-in') as HTMLElement | null; | |
| const h = inner?.scrollHeight || 180; | |
| setDurs((prev) => ({ ...prev, [gid]: Math.min(460, Math.max(170, Math.round(h * 1.4))) })); | |
| setCollapsed((c) => { const n = new Set(c); n.has(gid) ? n.delete(gid) : n.add(gid); return n; }); | |
| }; | |
| const renderItem = (s: Session) => { | |
| const m = dataFor(s); | |
| if (!visible(m)) return null; | |
| return ( | |
| <div key={s.id} className="ov-panel"> | |
| <Card s={m} color={colorOf[s.cli]} pending={pending(s.id)} onOpen={onOpen} /> | |
| </div> | |
| ); | |
| }; | |
| // ---- tile view: loose sessions pack into grids, groups get a fine outline ---- | |
| const tileFor = (s: Session) => { | |
| const m = dataFor(s); | |
| if (!visible(m)) return null; | |
| return <Tile key={s.id} s={m} color={colorOf[s.cli]} dim={archived.has(s.id)} pending={pending(s.id)} onOpen={() => setOpenId(s.id)} />; | |
| }; | |
| const tileBlocks: ReactNode[] = []; | |
| let looseTiles: ReactNode[] = []; | |
| const flushLoose = () => { | |
| if (looseTiles.length) tileBlocks.push(<div className="ovt-grid" key={`loose-${tileBlocks.length}`}>{looseTiles}</div>); | |
| looseTiles = []; | |
| }; | |
| for (const ref of tree.order) { | |
| if (ref.startsWith('s:')) { | |
| const s = sessById[ref.slice(2)]; | |
| if (s && eligible(s)) { const t = tileFor(s); if (t) looseTiles.push(t); } | |
| } else { | |
| const g = groupById[ref.slice(2)]; | |
| if (!g) continue; | |
| const members = g.sessionIds.map((id) => sessById[id]).filter(Boolean).filter(eligible) as Session[]; | |
| const shown = members.map(tileFor).filter(Boolean); | |
| if (!shown.length) continue; | |
| flushLoose(); | |
| tileBlocks.push( | |
| <div key={g.id} className="ovt-group"> | |
| <span className="ovt-glabel mono">{g.name}<span className="ovt-gn"> {shown.length}</span></span> | |
| <div className="ovt-grid">{shown}</div> | |
| </div>, | |
| ); | |
| } | |
| } | |
| flushLoose(); | |
| const openSess = openId ? sessById[openId] : null; | |
| const windowEl = openSess && ( | |
| <div className="ovw-backdrop" onClick={() => setOpenId(null)}> | |
| <div className="ovw-win" onClick={(e) => e.stopPropagation()}> | |
| <Card s={dataFor(openSess)} color={colorOf[openSess.cli]} pending={pending(openSess.id)} onOpen={onOpen} onClose={() => setOpenId(null)} /> | |
| </div> | |
| </div> | |
| ); | |
| if (view === 'tiles') { | |
| return ( | |
| <div className="ov-wrap"> | |
| <div className="ov-feed ovt-feed"> | |
| {tileBlocks.length === 0 && <div className="usage-msg mono">{filter === 'all' ? 'no agents yet — shells and file panes don’t appear here.' : 'nothing in this state.'}</div>} | |
| {tileBlocks} | |
| </div> | |
| {windowEl} | |
| </div> | |
| ); | |
| } | |
| const blocks: ReactNode[] = []; | |
| for (const ref of tree.order) { | |
| if (ref.startsWith('s:')) { | |
| const s = sessById[ref.slice(2)]; | |
| if (s && eligible(s)) { | |
| const el = renderItem(s); | |
| if (el) blocks.push(el); | |
| } | |
| } else { | |
| const g = groupById[ref.slice(2)]; | |
| if (!g) continue; | |
| const members = g.sessionIds.map((id) => sessById[id]).filter(Boolean).filter(eligible) as Session[]; | |
| const shown = members.map((s) => ({ s, el: renderItem(s) })).filter((x) => x.el); | |
| if (!shown.length) continue; | |
| const open = !collapsed.has(g.id); | |
| blocks.push( | |
| <div key={g.id} className={`ov-sec${open ? '' : ' closed'}`} style={{ '--dur': `${durs[g.id] ?? 360}ms` } as CSSProperties}> | |
| <button className="ov-sechead" onClick={(e) => toggleGroup(g.id, e.currentTarget)}> | |
| <Caret /> | |
| <span className="ov-sectitle">{g.name}</span> | |
| <span className="ov-secn mono">{shown.length}</span> | |
| <span className="ov-peek"> | |
| {shown.map(({ s }) => <span key={s.id} className={`status ${dataFor(s).state}`} />)} | |
| </span> | |
| </button> | |
| <div className="ov-drawer"><div className="ov-drawer-in"> | |
| <div className="ov-secbody">{shown.map(({ el }) => el)}</div> | |
| </div></div> | |
| <div className="ov-foot" /> | |
| </div>, | |
| ); | |
| } | |
| } | |
| return ( | |
| <div className="ov-wrap"> | |
| <div className="ov-feed"> | |
| {blocks.length === 0 && <div className="usage-msg mono">{filter === 'all' ? 'no agents yet — shells and file panes don’t appear here.' : 'nothing in this state.'}</div>} | |
| {blocks} | |
| </div> | |
| </div> | |
| ); | |
| } | |