thomwolf HF Staff Claude Opus 5 (1M context) commited on
Commit
0bb29ca
·
1 Parent(s): b97cdef

Sort the Overview by when something last happened

Browse files

The Overview has always been ordered by the tree: the sidebar's arrangement,
groups as capsules. That answers "where is this agent" and nothing else. Two
questions it could not answer are now a control in the bottom bar:

prompt — where did I last type something
answer — who answered most recently

Both read timestamps `/api/meta` already returns (`lastPromptTs`,
`lastAssistantTs`); no server change. `manual` is the default and is byte-for-
byte today's feed, so nobody's Overview moves unless they ask it to. The choice
persists in localStorage next to `am-ov-view`, per browser.

Three rules decide whether the ordering is any good, and they live in
web/src/lib/overviewSort.ts with their own tests rather than inline in the
component:

A RUNNING AGENT IS NOT RANKED. Its last message is whatever it said BEFORE it
started working, so ranking it by that buries the one agent that is doing
something under a wall of finished ones. Running agents come out in their own
block, pinned above the sorted list. "Running" is the rule the card already
draws with — `digest.running || state === 'working'` — minus remote agents,
whose digest reports `running` for a machine that is merely connected; there
the terminal-derived state is the honest signal.

A MISSING TIMESTAMP SINKS, AND SAYS WHY. `0` means "we do not know": a session
that never started, one with no transcript, a harness that writes none. Those
go to a labelled block at the bottom — `nothing sent yet` / `no reply yet` —
so a zero cannot pass itself off as "oldest" and nothing silently vanishes.

TIES KEEP THE MANUAL ORDER. The component walks the tree to build the list and
the sort is stable, so equal timestamps — and the whole undated tail — read as
the sidebar reads.

Sorting FLATTENS the grouping rather than ordering inside each capsule.
"Where did I last type something" is a question about the whole fleet; an
answer split across five capsules still has to be scanned capsule by capsule,
and the first card — the one place the eye lands — would only be the newest
agent of whichever group happens to sit first. The group is not lost, it moves
onto the card as `[Group] name`, exactly as a pane header spells it, and it is
the part that shrinks when the row runs out of room.

Sort and filter are independent: the filter says WHICH agents you are looking
at, the sort says WHERE each one is. The four filters are left as they are —
see the PR for why "done" earns its place next to a sort control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

web/package.json CHANGED
@@ -13,7 +13,7 @@
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
- "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
 
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
+ "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
web/src/App.tsx CHANGED
@@ -14,10 +14,10 @@ import Locked from './components/Locked';
14
  import BackupBanner from './components/BackupBanner';
15
  import Welcome from './components/Welcome';
16
  import * as api from './api';
17
- import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
18
  import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
19
  import { isPassive, isRemote } from './types';
20
- import { GridGlyph, ListGlyph } from './components/icons';
21
 
22
  // `?vvdebug=1` — a phone has no devtools, and the keyboard layout is a guess
23
  // when the app is embedded cross-origin. Read once: it never changes mid-run,
@@ -80,6 +80,15 @@ export default function App() {
80
  const [ovView, setOvViewRaw] = useState<'tiles' | 'list'>(() =>
81
  (localStorage.getItem('am-ov-view') === 'list' ? 'list' : 'tiles'));
82
  const setOvView = (v: 'tiles' | 'list') => { setOvViewRaw(v); localStorage.setItem('am-ov-view', v); };
 
 
 
 
 
 
 
 
 
83
  // Archiving: sessions quiet for longer than the configured window are hidden
84
  // from the sidebar and overview unless "archived" is checked. Derived, never
85
  // stored — flipping the setting instantly (un)archives.
@@ -926,6 +935,7 @@ export default function App() {
926
  clis={clis}
927
  tree={tree}
928
  filter={ovFilter}
 
929
  view={ovView}
930
  archived={archivedIds}
931
  showArchived={showArchived}
@@ -968,6 +978,17 @@ export default function App() {
968
  </button>
969
  ))}
970
  </div>
 
 
 
 
 
 
 
 
 
 
 
971
  <div className="seg ov-seg ov-viewseg">
972
  <button className={ovView === 'tiles' ? 'on' : ''} title="Tiles" onClick={() => setOvView('tiles')}><GridGlyph /></button>
973
  <button className={ovView === 'list' ? 'on' : ''} title="List" onClick={() => setOvView('list')}><ListGlyph /></button>
 
14
  import BackupBanner from './components/BackupBanner';
15
  import Welcome from './components/Welcome';
16
  import * as api from './api';
17
+ import type { Cli, GridSpec, MoveTarget, OverviewFilter, OverviewSort, Session, Tree } from './types';
18
  import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
19
  import { isPassive, isRemote } from './types';
20
+ import { GridGlyph, ListGlyph, SortGlyph } from './components/icons';
21
 
22
  // `?vvdebug=1` — a phone has no devtools, and the keyboard layout is a guess
23
  // when the app is embedded cross-origin. Read once: it never changes mid-run,
 
80
  const [ovView, setOvViewRaw] = useState<'tiles' | 'list'>(() =>
81
  (localStorage.getItem('am-ov-view') === 'list' ? 'list' : 'tiles'));
82
  const setOvView = (v: 'tiles' | 'list') => { setOvViewRaw(v); localStorage.setItem('am-ov-view', v); };
83
+ // Overview order: the tree's own arrangement (default) or ranked by a
84
+ // timestamp. A view preference like the two above it — per browser, and it
85
+ // survives a reload, because re-picking your order on every visit is the
86
+ // thing that makes a sort control feel like a toy.
87
+ const [ovSort, setOvSortRaw] = useState<OverviewSort>(() => {
88
+ const s = localStorage.getItem('am-ov-sort');
89
+ return s === 'prompt' || s === 'answer' ? s : 'manual';
90
+ });
91
+ const setOvSort = (v: OverviewSort) => { setOvSortRaw(v); localStorage.setItem('am-ov-sort', v); };
92
  // Archiving: sessions quiet for longer than the configured window are hidden
93
  // from the sidebar and overview unless "archived" is checked. Derived, never
94
  // stored — flipping the setting instantly (un)archives.
 
935
  clis={clis}
936
  tree={tree}
937
  filter={ovFilter}
938
+ sort={ovSort}
939
  view={ovView}
940
  archived={archivedIds}
941
  showArchived={showArchived}
 
978
  </button>
979
  ))}
980
  </div>
981
+ {/* Sort, independent of the filter beside it: one says WHICH agents
982
+ you are looking at, the other WHERE each one is in the feed. */}
983
+ <div className="seg ov-seg ov-sortseg">
984
+ <span className="ov-sortmark" aria-hidden="true"><SortGlyph /></span>
985
+ <button className={ovSort === 'manual' ? 'on' : ''} title="Your own order — the sidebar's groups and arrangement"
986
+ onClick={() => setOvSort('manual')}>manual</button>
987
+ <button className={ovSort === 'prompt' ? 'on' : ''} title="Newest message from you first"
988
+ onClick={() => setOvSort('prompt')}>prompt</button>
989
+ <button className={ovSort === 'answer' ? 'on' : ''} title="Newest reply from an agent first"
990
+ onClick={() => setOvSort('answer')}>answer</button>
991
+ </div>
992
  <div className="seg ov-seg ov-viewseg">
993
  <button className={ovView === 'tiles' ? 'on' : ''} title="Tiles" onClick={() => setOvView('tiles')}><GridGlyph /></button>
994
  <button className={ovView === 'list' ? 'on' : ''} title="List" onClick={() => setOvView('list')}><ListGlyph /></button>
web/src/components/Overview.tsx CHANGED
@@ -2,9 +2,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
2
  import type { CSSProperties, ReactNode } from 'react';
3
  import * as api from '../api';
4
  import type { MetaSession, TraceTurn } from '../api';
5
- import type { Cli, OverviewFilter, Session, SessionState, Tree } from '../types';
6
- import { isPassive } from '../types';
7
  import { renderMarkdown } from '../lib/markdown';
 
 
8
  import Logo from './Logo';
9
  import { SendGlyph } from './icons';
10
  import ExchangeView from './conversation/Exchange';
@@ -26,6 +28,20 @@ const eligible = (s: Session) => s.cli !== 'shell' && !isPassive(s.cli);
26
  const bucket = (state: SessionState): OverviewFilter =>
27
  state === 'working' ? 'working' : state === 'waiting' ? 'waiting' : 'quiet';
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  /** How much of the conversation the card reads. Cheap: one page, from the end. */
30
  const CARD_TAIL = 120;
31
  const CARD_TURNS = 5; // past this the card is the wrong tool, and says so
@@ -90,9 +106,12 @@ const Caret = () => (
90
  * two, and history grows one turn at a time instead of a stepper that replaced
91
  * the answer with an older one.
92
  */
93
- export function Card({ s, color, pending, isMobile, onOpen, onClose }: {
94
  s: MetaSession;
95
  color?: string;
 
 
 
96
  pending?: boolean; // digest still loading — show a shimmer instead of "no prompt yet"
97
  isMobile?: boolean;
98
  onOpen: (sid: string) => void;
@@ -199,6 +218,7 @@ export function Card({ s, color, pending, isMobile, onOpen, onClose }: {
199
  <div className="ov-id" onClick={() => onOpen(s.id)} title="Open pane">
200
  <span className={`status ${s.state}`} />
201
  <Logo cli={s.cli} size={12} tint={color} />
 
202
  <span className="ov-name mono">{s.name}</span>
203
  {ago && <span className="ov-ago">· {ago}</span>}
204
  <span className="spacer" />
@@ -315,7 +335,7 @@ export function Card({ s, color, pending, isMobile, onOpen, onClose }: {
315
  }
316
 
317
  /** Compact tile: status + prompt + state; click opens the conversation window. */
318
- function Tile({ s, color, dim, pending, onOpen }: { s: MetaSession; color?: string; dim?: boolean; pending?: boolean; onOpen: () => void }) {
319
  const d = s.digest;
320
  const running = !!d?.running || s.state === 'working';
321
  const last = Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0;
@@ -327,6 +347,7 @@ function Tile({ s, color, dim, pending, onOpen }: { s: MetaSession; color?: stri
327
  <div className="ovt-head">
328
  <span className={`status ${s.state}`} />
329
  <Logo cli={s.cli} size={12} tint={color} />
 
330
  <span className="ovt-name mono">{s.name}</span>
331
  <span className="ovt-ago">{pending ? '' : fmtAgo(last)}</span>
332
  </div>
@@ -354,11 +375,13 @@ function Tile({ s, color, dim, pending, onOpen }: { s: MetaSession; color?: stri
354
  }
355
 
356
  /** Mission control: one reading column — group capsules with their agents as
357
- * slabs, loose agents as standalone panels. */
358
- export default function Overview({ clis, tree, filter, view, archived, showArchived, meta, metaReady, isMobile, onOpen }: {
 
359
  clis: Cli[];
360
  tree: Tree;
361
  filter: OverviewFilter; // controlled by the bottom bar in App
 
362
  view: 'tiles' | 'list'; // controlled by the bottom bar in App
363
  archived: Set<string>;
364
  showArchived: boolean;
@@ -388,6 +411,51 @@ export default function Overview({ clis, tree, filter, view, archived, showArchi
388
  const visible = (s: MetaSession) =>
389
  (filter === 'all' || bucket(s.state) === filter) && (showArchived || !archived.has(s.id));
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  // Collapse at constant velocity: duration follows the group's height.
392
  const toggleGroup = (gid: string, el: HTMLElement) => {
393
  const inner = el.closest('.ov-sec')?.querySelector('.ov-drawer-in') as HTMLElement | null;
@@ -418,26 +486,68 @@ export default function Overview({ clis, tree, filter, view, archived, showArchi
418
  if (looseTiles.length) tileBlocks.push(<div className="ovt-grid" key={`loose-${tileBlocks.length}`}>{looseTiles}</div>);
419
  looseTiles = [];
420
  };
421
- for (const ref of tree.order) {
422
- if (ref.startsWith('s:')) {
423
- const s = sessById[ref.slice(2)];
424
- if (s && eligible(s)) { const t = tileFor(s); if (t) looseTiles.push(t); }
425
- } else {
426
- const g = groupById[ref.slice(2)];
427
- if (!g) continue;
428
- const members = g.sessionIds.map((id) => sessById[id]).filter(Boolean).filter(eligible) as Session[];
429
- const shown = members.map(tileFor).filter(Boolean);
430
- if (!shown.length) continue;
431
- flushLoose();
432
- tileBlocks.push(
433
- <div key={g.id} className="ovt-group">
434
- <span className="ovt-glabel mono">{g.name}<span className="ovt-gn"> {shown.length}</span></span>
435
- <div className="ovt-grid">{shown}</div>
436
- </div>,
437
- );
 
 
438
  }
 
439
  }
440
- flushLoose();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
 
442
  const openSess = openId ? sessById[openId] : null;
443
  const windowEl = openSess && (
@@ -448,20 +558,23 @@ export default function Overview({ clis, tree, filter, view, archived, showArchi
448
  </div>
449
  );
450
 
 
 
451
  if (view === 'tiles') {
 
452
  return (
453
  <div className="ov-wrap">
454
  <div className="ov-feed ovt-feed">
455
- {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>}
456
- {tileBlocks}
457
  </div>
458
  {windowEl}
459
  </div>
460
  );
461
  }
462
 
463
- const blocks: ReactNode[] = [];
464
- for (const ref of tree.order) {
465
  if (ref.startsWith('s:')) {
466
  const s = sessById[ref.slice(2)];
467
  if (s && eligible(s)) {
@@ -497,7 +610,7 @@ export default function Overview({ clis, tree, filter, view, archived, showArchi
497
  return (
498
  <div className="ov-wrap">
499
  <div className="ov-feed">
500
- {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>}
501
  {blocks}
502
  </div>
503
  </div>
 
2
  import type { CSSProperties, ReactNode } from 'react';
3
  import * as api from '../api';
4
  import type { MetaSession, TraceTurn } from '../api';
5
+ import type { Cli, OverviewFilter, OverviewSort, Session, SessionState, Tree } from '../types';
6
+ import { isPassive, isRemote } from '../types';
7
  import { renderMarkdown } from '../lib/markdown';
8
+ import { rankSessions, sortLabel } from '../lib/overviewSort';
9
+ import type { Rankable } from '../lib/overviewSort';
10
  import Logo from './Logo';
11
  import { SendGlyph } from './icons';
12
  import ExchangeView from './conversation/Exchange';
 
28
  const bucket = (state: SessionState): OverviewFilter =>
29
  state === 'working' ? 'working' : state === 'waiting' ? 'waiting' : 'quiet';
30
 
31
+ /**
32
+ * Is this agent at work right now — the fact the sorted feed pins on.
33
+ *
34
+ * The card's own rule (`digest.running || state === 'working'`) with one
35
+ * exception. A remote agent's digest reports `running` for a machine that is
36
+ * merely *connected* (`server/src/remote.js` — `isListening && !paused`), which
37
+ * is presence, not work; on that rule every laptop with the bridge open would
38
+ * sit pinned at the top of the feed forever. Its terminal-derived state is the
39
+ * honest signal there: `working` means it was handed a message it has not
40
+ * answered (see REMOTE_STATE_LABEL in types.ts).
41
+ */
42
+ const atWork = (m: MetaSession) =>
43
+ m.state === 'working' || (!isRemote(m.cli) && !!m.digest?.running);
44
+
45
  /** How much of the conversation the card reads. Cheap: one page, from the end. */
46
  const CARD_TAIL = 120;
47
  const CARD_TURNS = 5; // past this the card is the wrong tool, and says so
 
106
  * two, and history grows one turn at a time instead of a stepper that replaced
107
  * the answer with an older one.
108
  */
109
+ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
110
  s: MetaSession;
111
  color?: string;
112
+ // The group this agent belongs to, when the feed is not already drawing one
113
+ // around it (a sorted feed is flat). Same `[Group] name` a pane header uses.
114
+ group?: string | null;
115
  pending?: boolean; // digest still loading — show a shimmer instead of "no prompt yet"
116
  isMobile?: boolean;
117
  onOpen: (sid: string) => void;
 
218
  <div className="ov-id" onClick={() => onOpen(s.id)} title="Open pane">
219
  <span className={`status ${s.state}`} />
220
  <Logo cli={s.cli} size={12} tint={color} />
221
+ {group && <span className="ov-gtag mono">[{group}]</span>}
222
  <span className="ov-name mono">{s.name}</span>
223
  {ago && <span className="ov-ago">· {ago}</span>}
224
  <span className="spacer" />
 
335
  }
336
 
337
  /** Compact tile: status + prompt + state; click opens the conversation window. */
338
+ function Tile({ s, color, group, dim, pending, onOpen }: { s: MetaSession; color?: string; group?: string | null; dim?: boolean; pending?: boolean; onOpen: () => void }) {
339
  const d = s.digest;
340
  const running = !!d?.running || s.state === 'working';
341
  const last = Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0;
 
347
  <div className="ovt-head">
348
  <span className={`status ${s.state}`} />
349
  <Logo cli={s.cli} size={12} tint={color} />
350
+ {group && <span className="ov-gtag mono">[{group}]</span>}
351
  <span className="ovt-name mono">{s.name}</span>
352
  <span className="ovt-ago">{pending ? '' : fmtAgo(last)}</span>
353
  </div>
 
375
  }
376
 
377
  /** Mission control: one reading column — group capsules with their agents as
378
+ * slabs, loose agents as standalone panels. Unless a sort is on, in which case
379
+ * it is one flat ranked column instead (see §"sorted feed" below). */
380
+ export default function Overview({ clis, tree, filter, sort, view, archived, showArchived, meta, metaReady, isMobile, onOpen }: {
381
  clis: Cli[];
382
  tree: Tree;
383
  filter: OverviewFilter; // controlled by the bottom bar in App
384
+ sort: OverviewSort; // ditto, and independent of the filter
385
  view: 'tiles' | 'list'; // controlled by the bottom bar in App
386
  archived: Set<string>;
387
  showArchived: boolean;
 
411
  const visible = (s: MetaSession) =>
412
  (filter === 'all' || bucket(s.state) === filter) && (showArchived || !archived.has(s.id));
413
 
414
+ // Which group each agent is in, by name. Only the sorted feed needs it: the
415
+ // manual feed draws the group as a frame around its members and would be
416
+ // saying it twice (the same rule the sidebar and pane headers follow —
417
+ // web/src/lib/sessionTitle.ts).
418
+ const groupNameOf = useMemo(() => {
419
+ const m: Record<string, string> = {};
420
+ for (const g of tree.groups) for (const id of g.sessionIds) m[id] = g.name;
421
+ return m;
422
+ }, [tree.groups]);
423
+
424
+ // ---- sorted feed: flat, ranked, groups become a prefix ----
425
+ //
426
+ // Sorting FLATTENS the grouping rather than ordering inside each capsule.
427
+ // "Where did I last type something" is a question about the whole fleet; an
428
+ // answer split across five capsules still has to be scanned capsule by
429
+ // capsule, and the first card of the feed — the one place the eye lands —
430
+ // would only be the newest agent *of whichever group happens to be first in
431
+ // the sidebar*. Which group an agent is in is not lost, it moves onto the
432
+ // card as `[Group] name`, exactly as a pane header spells it.
433
+ const sorted = sort !== 'manual';
434
+ const sections = useMemo(() => {
435
+ if (!sorted) return null;
436
+ // Walk the tree so ties (and the whole undated tail) come out in the order
437
+ // you arranged them in the sidebar.
438
+ const items: (Rankable & { m: MetaSession })[] = [];
439
+ for (const ref of tree.order) {
440
+ const ids = ref.startsWith('s:') ? [ref.slice(2)] : groupById[ref.slice(2)]?.sessionIds ?? [];
441
+ for (const id of ids) {
442
+ const s = sessById[id];
443
+ if (!s || !eligible(s)) continue;
444
+ const m = dataFor(s);
445
+ if (!visible(m)) continue;
446
+ items.push({
447
+ id,
448
+ m,
449
+ lastPromptTs: m.digest?.lastPromptTs || 0,
450
+ lastAssistantTs: m.digest?.lastAssistantTs || 0,
451
+ running: atWork(m),
452
+ });
453
+ }
454
+ }
455
+ return rankSessions(items, sort);
456
+ // eslint-disable-next-line react-hooks/exhaustive-deps
457
+ }, [sorted, sort, tree.order, sessById, groupById, meta, metaReady, filter, archived, showArchived]);
458
+
459
  // Collapse at constant velocity: duration follows the group's height.
460
  const toggleGroup = (gid: string, el: HTMLElement) => {
461
  const inner = el.closest('.ov-sec')?.querySelector('.ov-drawer-in') as HTMLElement | null;
 
486
  if (looseTiles.length) tileBlocks.push(<div className="ovt-grid" key={`loose-${tileBlocks.length}`}>{looseTiles}</div>);
487
  looseTiles = [];
488
  };
489
+ if (!sorted) {
490
+ for (const ref of tree.order) {
491
+ if (ref.startsWith('s:')) {
492
+ const s = sessById[ref.slice(2)];
493
+ if (s && eligible(s)) { const t = tileFor(s); if (t) looseTiles.push(t); }
494
+ } else {
495
+ const g = groupById[ref.slice(2)];
496
+ if (!g) continue;
497
+ const members = g.sessionIds.map((id) => sessById[id]).filter(Boolean).filter(eligible) as Session[];
498
+ const shown = members.map(tileFor).filter(Boolean);
499
+ if (!shown.length) continue;
500
+ flushLoose();
501
+ tileBlocks.push(
502
+ <div key={g.id} className="ovt-group">
503
+ <span className="ovt-glabel mono">{g.name}<span className="ovt-gn"> {shown.length}</span></span>
504
+ <div className="ovt-grid">{shown}</div>
505
+ </div>,
506
+ );
507
+ }
508
  }
509
+ flushLoose();
510
  }
511
+
512
+ /**
513
+ * The ranked feed: at most three labelled blocks, in this order.
514
+ *
515
+ * · running now — pinned, because a working agent's last message is whatever
516
+ * it said BEFORE it started, and ranking it by that buries the one agent
517
+ * that is doing something under a wall of finished ones.
518
+ * · the sort itself — what you asked for, newest first.
519
+ * · the tail — an agent with no such message at all (never started, no
520
+ * transcript, a harness that writes none). Last, and labelled, so a 0
521
+ * timestamp cannot pass itself off as "oldest".
522
+ *
523
+ * Every block is the same card or tile the manual feed draws; only the order
524
+ * and the `[Group]` prefix change.
525
+ */
526
+ const rankedBlocks = (): ReactNode[] => {
527
+ if (!sections) return [];
528
+ type Row = { id: string; m: MetaSession };
529
+ const body = (rows: Row[]) => (view === 'tiles'
530
+ ? <div className="ovt-grid">{rows.map(({ m }) => (
531
+ <Tile key={m.id} s={m} color={colorOf[m.cli]} group={groupNameOf[m.id]} dim={archived.has(m.id)}
532
+ pending={pending(m.id)} onOpen={() => setOpenId(m.id)} />
533
+ ))}</div>
534
+ : rows.map(({ m }) => (
535
+ <div key={m.id} className="ov-panel">
536
+ <Card s={m} color={colorOf[m.cli]} group={groupNameOf[m.id]} pending={pending(m.id)} isMobile={isMobile} onOpen={onOpen} />
537
+ </div>
538
+ )));
539
+ const block = (key: string, label: string, rows: Row[]) => rows.length === 0 ? null : (
540
+ <div className="ov-sortsec" key={key}>
541
+ <div className="ov-sortlbl mono"><span>{label}</span><span className="ov-sortrule" /><span className="ov-sortn">{rows.length}</span></div>
542
+ {body(rows)}
543
+ </div>
544
+ );
545
+ return [
546
+ block('running', 'running now', sections.running),
547
+ block('dated', sortLabel(sort), sections.dated),
548
+ block('undated', sort === 'prompt' ? 'nothing sent yet' : 'no reply yet', sections.undated),
549
+ ].filter(Boolean) as ReactNode[];
550
+ };
551
 
552
  const openSess = openId ? sessById[openId] : null;
553
  const windowEl = openSess && (
 
558
  </div>
559
  );
560
 
561
+ const empty = <div className="usage-msg mono">{filter === 'all' ? 'no agents yet — shells and file panes don’t appear here.' : 'nothing in this state.'}</div>;
562
+
563
  if (view === 'tiles') {
564
+ const content = sorted ? rankedBlocks() : tileBlocks;
565
  return (
566
  <div className="ov-wrap">
567
  <div className="ov-feed ovt-feed">
568
+ {content.length === 0 && empty}
569
+ {content}
570
  </div>
571
  {windowEl}
572
  </div>
573
  );
574
  }
575
 
576
+ const blocks: ReactNode[] = sorted ? rankedBlocks() : [];
577
+ if (!sorted) for (const ref of tree.order) {
578
  if (ref.startsWith('s:')) {
579
  const s = sessById[ref.slice(2)];
580
  if (s && eligible(s)) {
 
610
  return (
611
  <div className="ov-wrap">
612
  <div className="ov-feed">
613
+ {blocks.length === 0 && empty}
614
  {blocks}
615
  </div>
616
  </div>
web/src/components/icons.tsx CHANGED
@@ -229,6 +229,13 @@ export const GridGlyph = ({ className }: { className?: string }) => (
229
  </G>
230
  );
231
 
 
 
 
 
 
 
 
232
  export const PlusGlyph = ({ className }: { className?: string }) => (
233
  <G className={className}>
234
  <path d="M8 3.2v9.6M3.2 8h9.6" />
 
229
  </G>
230
  );
231
 
232
+ // Overview order: lines stepping down, longest first — a list that is ranked.
233
+ export const SortGlyph = ({ className }: { className?: string }) => (
234
+ <G className={className}>
235
+ <path d="M2.6 4h10.8M2.6 8h7.2M2.6 12h3.6" />
236
+ </G>
237
+ );
238
+
239
  export const PlusGlyph = ({ className }: { className?: string }) => (
240
  <G className={className}>
241
  <path d="M8 3.2v9.6M3.2 8h9.6" />
web/src/lib/overviewSort.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Ordering the Overview by when something last happened.
2
+ //
3
+ // The Overview's own order is the sidebar's: the tree, hand-arranged, groups as
4
+ // capsules. That answers "where is this agent" and nothing else. The two
5
+ // questions it cannot answer are "where did I last type something" and "who
6
+ // answered most recently", and both are a timestamp the digest already carries
7
+ // (`lastPromptTs` / `lastAssistantTs`, ms epoch, 0 when unknown).
8
+ //
9
+ // Three rules, all of them here rather than in the component, because they are
10
+ // the part that can be wrong:
11
+ //
12
+ // 1. **A running agent is not ranked.** Its last message is whatever it said
13
+ // before it started working, so ranking it by that buries the agent that is
14
+ // doing something right now under agents that are done. They come out in
15
+ // their own section, and the caller pins it above the sorted list.
16
+ // 2. **A missing timestamp sinks.** `0` means "we do not know" — a session
17
+ // that never started, has no transcript, or runs a harness that writes
18
+ // none. Sorting descending on a number would float those to the bottom
19
+ // anyway, but only by accident of arithmetic; they get their own section so
20
+ // the caller can say what they are instead of implying they are stale.
21
+ // 3. **Ties keep the manual order.** The caller passes items in tree order and
22
+ // the sort is stable, so equal timestamps — and the whole undated section —
23
+ // read as the sidebar reads.
24
+
25
+ import type { OverviewSort } from '../types';
26
+
27
+ /** What ordering needs to know about a session. Deliberately not `MetaSession`:
28
+ * the three facts, so this is testable without a digest or a React tree. */
29
+ export interface Rankable {
30
+ id: string;
31
+ lastPromptTs: number; // ms epoch, 0 = unknown
32
+ lastAssistantTs: number; // ms epoch, 0 = unknown
33
+ running: boolean;
34
+ }
35
+
36
+ /** The timestamp a given sort reads. 0 for the manual order, which reads none. */
37
+ export function sortTs(s: Rankable, sort: OverviewSort): number {
38
+ if (sort === 'prompt') return s.lastPromptTs || 0;
39
+ if (sort === 'answer') return s.lastAssistantTs || 0;
40
+ return 0;
41
+ }
42
+
43
+ export interface Sections<T> {
44
+ /** At work now — pinned above the sorted list, in the caller's order. */
45
+ running: T[];
46
+ /** The answer to the question, newest first. */
47
+ dated: T[];
48
+ /** No such message ever: never started, no transcript, unsupported harness. */
49
+ undated: T[];
50
+ }
51
+
52
+ /**
53
+ * Split into the three sections above and sort the middle one, newest first.
54
+ *
55
+ * `manual` is the identity: everything lands in `dated` untouched, so a caller
56
+ * that renders the sections in order still gets the tree's own arrangement.
57
+ */
58
+ export function rankSessions<T extends Rankable>(items: T[], sort: OverviewSort): Sections<T> {
59
+ if (sort === 'manual') return { running: [], dated: items.slice(), undated: [] };
60
+ const running: T[] = [];
61
+ const dated: T[] = [];
62
+ const undated: T[] = [];
63
+ for (const it of items) {
64
+ if (it.running) running.push(it);
65
+ else if (sortTs(it, sort) > 0) dated.push(it);
66
+ else undated.push(it);
67
+ }
68
+ // Stable (ES2019+): equal timestamps stay in the order they arrived, which is
69
+ // the tree's.
70
+ dated.sort((a, b) => sortTs(b, sort) - sortTs(a, sort));
71
+ return { running, dated, undated };
72
+ }
73
+
74
+ /** What the sorted block is sorted BY, spelled out above it in the feed. */
75
+ export function sortLabel(sort: OverviewSort): string {
76
+ return sort === 'prompt' ? 'by your last message' : sort === 'answer' ? 'by the last reply' : '';
77
+ }
web/src/styles.css CHANGED
@@ -81,8 +81,13 @@ body {
81
  .bkfail-link:hover { color: var(--danger); text-decoration-style: solid; }
82
  .zoombar { display: flex; align-items: center; gap: 4px; padding: 4px 8px; margin-top: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); flex: none; }
83
  .zoombar .spacer { flex: 1; }
84
- /* overview bottom bar: filter + view segments, centered */
85
- .zoombar.ov-bar { justify-content: center; gap: 8px; }
 
 
 
 
 
86
  .ov-viewseg button { display: inline-flex; align-items: center; padding: 4.5px 9px; }
87
  .ov-viewseg svg { width: 13px; height: 13px; }
88
 
@@ -235,6 +240,21 @@ body {
235
 
236
  .ov-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); }
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  /* group capsule: header bar / spaced flat slabs / footer strip that merges away */
239
  .ov-sec { display: flex; flex-direction: column; }
240
  .ov-sechead { display: flex; align-items: center; gap: 7px; width: 100%; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-xl) var(--r-xl) 0 0; padding: 8px 14px; font: inherit; color: var(--text); cursor: pointer; text-align: left; transition: border-radius var(--dur, 0.36s) ease; }
 
81
  .bkfail-link:hover { color: var(--danger); text-decoration-style: solid; }
82
  .zoombar { display: flex; align-items: center; gap: 4px; padding: 4px 8px; margin-top: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); flex: none; }
83
  .zoombar .spacer { flex: 1; }
84
+ /* overview bottom bar: filter · sort · view segments, centered — three of them
85
+ don't fit a phone on one line, so it wraps rather than overflowing */
86
+ .zoombar.ov-bar { justify-content: center; gap: 8px; flex-wrap: wrap; row-gap: 5px; }
87
+ /* the sort segment says what it is, since "prompt"/"answer" next to a row of
88
+ filter words would otherwise read as two more filters */
89
+ .ov-sortmark { display: inline-flex; align-items: center; padding: 0 5px 0 7px; color: var(--muted); }
90
+ .ov-sortmark svg { width: 12px; height: 12px; display: block; }
91
  .ov-viewseg button { display: inline-flex; align-items: center; padding: 4.5px 9px; }
92
  .ov-viewseg svg { width: 13px; height: 13px; }
93
 
 
240
 
241
  .ov-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); }
242
 
243
+ /* ---- sorted feed: labelled blocks instead of group capsules ---- */
244
+ /* In the list the cards keep the feed's own spacing; tiles pack tighter, as
245
+ they do inside a group box. */
246
+ .ov-sortsec { display: flex; flex-direction: column; gap: 18px; }
247
+ .ovt-feed .ov-sortsec { gap: 8px; }
248
+ .ovt-feed .ov-sortsec > .ovt-grid { margin: 0 11px; }
249
+ .ov-sortlbl { display: flex; align-items: center; gap: 9px; padding: 0 12px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; color: var(--muted); }
250
+ .ov-sortrule { flex: 1; height: 1px; background: var(--border); }
251
+ .ov-sortn { color: var(--border-strong); }
252
+ /* The group an agent is in, when the feed is flat and no capsule says it. It
253
+ gives up its pixels first (shrink 5×, and never more than half the row): the
254
+ prefix is context, the name is the thing you are looking for. */
255
+ .ov-gtag { flex: 0 5 auto; min-width: 0; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-weight: 500; font-size: 11.5px; }
256
+ .ovt-head .ov-gtag { font-size: 10.5px; }
257
+
258
  /* group capsule: header bar / spaced flat slabs / footer strip that merges away */
259
  .ov-sec { display: flex; flex-direction: column; }
260
  .ov-sechead { display: flex; align-items: center; gap: 7px; width: 100%; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-xl) var(--r-xl) 0 0; padding: 8px 14px; font: inherit; color: var(--text); cursor: pointer; text-align: left; transition: border-radius var(--dur, 0.36s) ease; }
web/src/types.ts CHANGED
@@ -103,6 +103,11 @@ export interface RemoteInfo {
103
 
104
  export type OverviewFilter = 'all' | 'waiting' | 'working' | 'quiet';
105
 
 
 
 
 
 
106
  export type MoveTarget =
107
  | { kind: 'into'; groupId: string }
108
  | { kind: 'pair'; sessionId: string }
 
103
 
104
  export type OverviewFilter = 'all' | 'waiting' | 'working' | 'quiet';
105
 
106
+ // How the Overview is ordered. `manual` is the tree's own arrangement — groups
107
+ // as capsules, agents where you put them; the other two flatten that and rank
108
+ // every agent by a timestamp from its digest. See web/src/lib/overviewSort.ts.
109
+ export type OverviewSort = 'manual' | 'prompt' | 'answer';
110
+
111
  export type MoveTarget =
112
  | { kind: 'into'; groupId: string }
113
  | { kind: 'pair'; sessionId: string }
web/test/overviewSort.test.mjs ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The three rules the Overview's ordering lives or dies by: a running agent is
2
+ // not ranked, a missing timestamp sinks (and is never mistaken for "oldest"),
3
+ // and ties keep the order the caller passed — the sidebar's.
4
+ //
5
+ // Same shape as sessionTitle.test.mjs — no test runner, esbuild transpiles the
6
+ // module and we import it. Run with: node test/overviewSort.test.mjs
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { build } from 'esbuild';
13
+
14
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
15
+ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ovsort-')), 'overviewSort.mjs');
16
+ await build({
17
+ entryPoints: [path.join(HERE, '../src/lib/overviewSort.ts')],
18
+ outfile: out, format: 'esm', bundle: false, logLevel: 'error',
19
+ });
20
+ const { rankSessions, sortTs, sortLabel } = await import(pathToFileURL(out).href);
21
+
22
+ let failed = 0;
23
+ const check = (what, fn) => {
24
+ try { fn(); console.log(` ok ${what}`); } catch (e) {
25
+ failed++;
26
+ console.log(` FAIL ${what}\n ${e.message.split('\n')[0]}`);
27
+ }
28
+ };
29
+
30
+ const a = (id, p, ans, running = false) => ({ id, lastPromptTs: p, lastAssistantTs: ans, running });
31
+ const ids = (xs) => xs.map((x) => x.id);
32
+
33
+ // A fleet with every awkward case in it, in "sidebar order".
34
+ const fleet = [
35
+ a('old', 1_000, 2_000), // answered long ago
36
+ a('busy', 5_000, 4_000, true), // at work right now
37
+ a('fresh', 9_000, 3_000), // you typed here last
38
+ a('blank', 0, 0), // no digest at all
39
+ a('unanswered', 8_000, 0), // you asked, nothing came back yet
40
+ a('replied', 6_000, 9_500), // the newest reply
41
+ ];
42
+
43
+ console.log('sortTs');
44
+ check('reads the field the sort names', () => {
45
+ assert.equal(sortTs(fleet[0], 'prompt'), 1_000);
46
+ assert.equal(sortTs(fleet[0], 'answer'), 2_000);
47
+ });
48
+ check('the manual order reads no clock', () => assert.equal(sortTs(fleet[0], 'manual'), 0));
49
+
50
+ console.log('rankSessions · manual');
51
+ check('is the identity — the tree, untouched, one block', () => {
52
+ const r = rankSessions(fleet, 'manual');
53
+ assert.deepEqual(ids(r.dated), ids(fleet));
54
+ assert.deepEqual(r.running, []);
55
+ assert.deepEqual(r.undated, []);
56
+ });
57
+ check('does not mutate the caller\'s array', () => {
58
+ const input = fleet.slice();
59
+ rankSessions(input, 'answer');
60
+ assert.deepEqual(ids(input), ids(fleet));
61
+ });
62
+
63
+ console.log('rankSessions · by last prompt');
64
+ check('newest message from you first', () => {
65
+ const r = rankSessions(fleet, 'prompt');
66
+ assert.deepEqual(ids(r.dated), ['fresh', 'unanswered', 'replied', 'old']);
67
+ });
68
+ check('the running agent is pinned out of the ranking, not into the top', () => {
69
+ const r = rankSessions(fleet, 'prompt');
70
+ assert.deepEqual(ids(r.running), ['busy']);
71
+ assert.ok(!ids(r.dated).includes('busy'));
72
+ });
73
+ check('no timestamp sinks into its own block, and is still there', () => {
74
+ const r = rankSessions(fleet, 'prompt');
75
+ assert.deepEqual(ids(r.undated), ['blank']);
76
+ const all = [...ids(r.running), ...ids(r.dated), ...ids(r.undated)].sort();
77
+ assert.deepEqual(all, ids(fleet).sort());
78
+ });
79
+
80
+ console.log('rankSessions · by last answer');
81
+ check('newest reply first', () => {
82
+ const r = rankSessions(fleet, 'answer');
83
+ assert.deepEqual(ids(r.dated), ['replied', 'fresh', 'old']);
84
+ });
85
+ check('asked-but-never-answered is undated here, dated under prompt', () => {
86
+ assert.deepEqual(ids(rankSessions(fleet, 'answer').undated), ['blank', 'unanswered']);
87
+ assert.ok(ids(rankSessions(fleet, 'prompt').dated).includes('unanswered'));
88
+ });
89
+
90
+ console.log('rankSessions · ties and edges');
91
+ check('equal timestamps keep the order they came in', () => {
92
+ const tied = [a('c', 5, 5), a('a', 5, 5), a('b', 5, 5)];
93
+ assert.deepEqual(ids(rankSessions(tied, 'prompt').dated), ['c', 'a', 'b']);
94
+ });
95
+ check('the undated tail keeps the order it came in', () => {
96
+ const none = [a('z', 0, 0), a('y', 0, 0)];
97
+ assert.deepEqual(ids(rankSessions(none, 'answer').undated), ['z', 'y']);
98
+ });
99
+ check('a running agent with no digest is still running, not undated', () => {
100
+ const r = rankSessions([a('n', 0, 0, true)], 'prompt');
101
+ assert.deepEqual(ids(r.running), ['n']);
102
+ assert.deepEqual(r.undated, []);
103
+ });
104
+ check('an empty fleet gives three empty blocks', () => {
105
+ const r = rankSessions([], 'answer');
106
+ assert.deepEqual([r.running, r.dated, r.undated], [[], [], []]);
107
+ });
108
+
109
+ console.log('sortLabel');
110
+ check('names what the block is sorted by', () => {
111
+ assert.equal(sortLabel('prompt'), 'by your last message');
112
+ assert.equal(sortLabel('answer'), 'by the last reply');
113
+ assert.equal(sortLabel('manual'), '');
114
+ });
115
+
116
+ console.log(failed ? `\n${failed} failed` : '\nall passed');
117
+ process.exit(failed ? 1 : 0);