lvwerra's picture
lvwerra HF Staff
Overview replies echo instantly as the new prompt
77deb70
Raw
History Blame Contribute Delete
18.8 kB
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>
);
}