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 = () => ( ); 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(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 (
onOpen(s.id)} title="Open pane"> {s.name} {ago && · {ago}} open ↗ {onClose && }
{promptText ? (
setPromptOpen((v) => !v)} >{promptOpen ? (sent ? sent.text : (d?.lastPromptRaw || promptText)) : promptText}
) : pending ? (
) : (
no prompt yet
)} {(metaBits.length > 0 || hist.length > 0) && (
{metaBits.join(' · ')} {hist.length > 0 && ( {idx > 0 && turn {totalTurns - idx}/{totalTurns}} )}
)} {showLiveProgress ? (
{answerText && d && d.lastAssistantTs >= d.lastPromptTs && (
{answerText}
)}
running
) : answerText ? (
{expanded ? (
) : (
{answerText}
)}
) : null}