File size: 18,822 Bytes
7c91582
 
7d9aec4
 
b53cd8f
ece21ed
7d9aec4
 
 
 
 
 
7c91582
 
 
7d9aec4
7c91582
 
7d9aec4
a9cda9b
b53cd8f
7c91582
 
 
 
 
7d9aec4
e34ce4a
7c91582
 
e34ce4a
7c91582
585ab8a
7c91582
 
7d9aec4
7c91582
 
77deb70
 
 
 
7c91582
a3d2eef
8e5d9a0
585ab8a
a9cda9b
 
7c91582
77deb70
 
8e5d9a0
 
 
77deb70
8e5d9a0
 
 
 
a9cda9b
7d9aec4
 
7c91582
 
 
7d9aec4
7c91582
7d9aec4
77deb70
8e5d9a0
77deb70
585ab8a
7d9aec4
7c91582
 
7d9aec4
7c91582
7d9aec4
a9cda9b
7c91582
77deb70
8e5d9a0
 
 
 
7c91582
 
 
 
 
 
8e5d9a0
7d9aec4
 
 
7c91582
 
 
7d9aec4
7c91582
7d9aec4
7c91582
585ab8a
7d9aec4
 
8e5d9a0
a3d2eef
 
 
 
77deb70
e34ce4a
 
7d9aec4
8e5d9a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9cda9b
 
8e5d9a0
 
 
 
 
 
 
 
7c91582
 
8e5d9a0
7c91582
8e5d9a0
7c91582
 
7d9aec4
7c91582
7d9aec4
8e5d9a0
 
585ab8a
8e5d9a0
585ab8a
8e5d9a0
 
 
 
 
 
 
585ab8a
8e5d9a0
 
 
 
585ab8a
8e5d9a0
 
 
585ab8a
8e5d9a0
7c91582
7d9aec4
 
 
 
585ab8a
e34ce4a
585ab8a
 
 
 
 
 
 
 
 
 
 
 
e34ce4a
585ab8a
e34ce4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585ab8a
 
 
 
7c91582
b53cd8f
585ab8a
b53cd8f
 
 
585ab8a
 
 
b53cd8f
 
e34ce4a
 
 
 
 
 
a9cda9b
7c91582
585ab8a
 
 
 
 
 
 
7c91582
7cc344c
 
 
7d9aec4
 
a9cda9b
e34ce4a
 
 
7cc344c
 
 
e34ce4a
 
 
a9cda9b
7d9aec4
436d14d
7d9aec4
 
 
e34ce4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9cda9b
 
 
e34ce4a
 
a9cda9b
585ab8a
 
7c91582
 
 
 
 
 
 
 
 
 
 
 
 
 
e34ce4a
7c91582
a9cda9b
 
7c91582
585ab8a
 
 
 
e34ce4a
585ab8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e34ce4a
585ab8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c91582
a9cda9b
 
 
7c91582
 
 
 
a9cda9b
 
 
 
7c91582
 
a9cda9b
 
7c91582
 
 
 
 
 
 
 
a9cda9b
7c91582
 
 
 
a9cda9b
 
 
 
7d9aec4
 
 
7c91582
 
 
 
7d9aec4
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
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>
  );
}