Spaces:
Running
Sort the Overview by when something last happened
Browse filesThe 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 +1 -1
- web/src/App.tsx +23 -2
- web/src/components/Overview.tsx +142 -29
- web/src/components/icons.tsx +7 -0
- web/src/lib/overviewSort.ts +77 -0
- web/src/styles.css +22 -2
- web/src/types.ts +5 -0
- web/test/overviewSort.test.mjs +117 -0
|
@@ -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": {
|
|
@@ -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>
|
|
@@ -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 |
-
|
|
|
|
| 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 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
<
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
|
|
|
|
|
|
| 438 |
}
|
|
|
|
| 439 |
}
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 456 |
-
{
|
| 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 &&
|
| 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>
|
|
@@ -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" />
|
|
@@ -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 |
+
}
|
|
@@ -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
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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; }
|
|
@@ -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 }
|
|
@@ -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);
|