{answerText && d && d.lastAssistantTs >= d.lastPromptTs && (
{answerText}
)}
running
) : answerText ? (
{expanded ? (
) : (
{answerText}
)}
) : null}
❯
{failed &&
failed to reach the agent
}
);
}
/** 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 (
{s.name}{pending ? '' : fmtAgo(last)}
{pending ? (
<>
>
) : (
<>
{d?.lastPromptText
?
{d.lastPromptText}
:
no prompt yet
}
{running
?
running
: s.state === 'stopped'
?
stopped
: d?.lastAssistantText
?
✓ done
:
idle
}
>
)}
);
}
/** 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;
showArchived: boolean;
onOpen: (sid: string) => void;
}) {
const [meta, setMeta] = useState>({});
// 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>(new Set());
const bulkDone = useRef(false);
const [collapsed, setCollapsed] = useState>(new Set());
const [durs, setDurs] = useState>({});
const [openId, setOpenId] = useState(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 (
);
};
// ---- 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 setOpenId(s.id)} />;
};
const tileBlocks: ReactNode[] = [];
let looseTiles: ReactNode[] = [];
const flushLoose = () => {
if (looseTiles.length) tileBlocks.push(
{looseTiles}
);
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(