From 8103fded3e0594892082483acb760bd74142d755 Mon Sep 17 00:00:00 2001 From: Agent Manager Date: Sun, 16 Aug 2026 17:30:20 +0000 Subject: [PATCH 1/3] One button on a session row, and archive as the way out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar row carried up to four controls. It keeps one: `×`, which archives. **Start** was the row's own `onClick` spelled a second time — `onOpenSession(...)` either way. **Stop** went because an idle CLI costs nothing, so "pause this" is not a real case; the real one is a runaway agent, which is urgent and rare, and in that moment you open the pane, where Ctrl-C interrupts with the CLI's own semantics. Its last UI consumer is gone, but the capability stays: archiving calls it. **Archiving is now stored, and it stops the agent.** It had to become stored: "I am finished with this one" cannot be expressed as an absence of activity, which is all the idle window can measure. `archivedAt` lives on the session record, so it survives a reload and means the same thing on every device. The idle window stays exactly as it was — still derived, still hiding a quiet session from the working list. Both roads lead to the archived view, but they are NOT the same road, and the PR argues why: only the deliberate one unlocks delete. **Delete only exists in the archived view**, and only for a session the operator archived. The server enforces it (409 `not-archived`) rather than the sidebar alone, so the rule holds for any caller, and it leaves an obvious hook for #76's `?ifNeverStarted=1`. **Trace actions moved to the trace.** #84 did not remove the per-row trace buttons — its own body says so — so this does. Share and "continue in a new agent" are now on the trace pane's header, which is the thing they act on. Not the reader's `i`: that panel is `TraceInfo`, and a trace pane does not use it. Reading a session's own transcript is what reader mode already is, so `openTrace` and its dead pane-spawning path go. The remote reconnect/disconnect pair is untouched: it is a line to another machine, not a local process. Group rows are untouched. `server/test/archive.test.mjs`, 16 checks against a real server: a live session refuses to delete with a code and a sentence, an archived one deletes, the flag is on disk rather than in a browser, restore puts it back and makes it undeletable again, archiving a running agent stops it, and `stopped` is still the state it lands in. This completes step 7 of `docs/conversation-view.md`'s own sequence — "the sidebar loses its trace buttons and `openTrace`" — so §3.4's table now records where each control went rather than where it was going, and no longer says agent rows keep stop/play and delete. `reader-info.test.mjs` gains two checks in the block that already asserted what the sidebar no longer carries: the row holds exactly one control, and it archives. Co-Authored-By: Claude Opus 5 --- docs/conversation-view.md | 29 +++++--- server/package.json | 2 +- server/reader-info.test.mjs | 16 ++++- server/src/index.js | 45 ++++++++++++ server/test/archive.test.mjs | 114 +++++++++++++++++++++++++++++++ web/src/App.tsx | 80 ++++++++++++++-------- web/src/api.ts | 12 +++- web/src/components/Sidebar.tsx | 78 ++++++++++++++------- web/src/components/TracePane.tsx | 18 ++++- web/src/types.ts | 4 ++ 10 files changed, 326 insertions(+), 72 deletions(-) create mode 100644 server/test/archive.test.mjs diff --git a/docs/conversation-view.md b/docs/conversation-view.md index d8593f3..81ac6ca 100644 --- a/docs/conversation-view.md +++ b/docs/conversation-view.md @@ -474,14 +474,17 @@ pane with nothing to render (a shell) simply stays a terminal. ### 3.4 What leaves the sidebar -| Today | Tomorrow | +| Was on the row | Where it is now | |---|---| -| `Read this session's trace` on every agent row (`Sidebar.tsx:245`) | bottom bar → **reader** | -| `Share this session` on every agent row (`Sidebar.tsx:246`) | pane header → **share** | -| A `trace` **session row per read** (`App.tsx:openTrace`) | **gone** — no duplicate rows | -| Trace row's `Share` (`Sidebar.tsx:237`) | trace pane header (imported traces keep a pane) | -| Trace row's `Handover` (`Sidebar.tsx:238`) | reader / trace-pane footer | -| Quick-add **Trace** = open a shared dataset (`Sidebar.tsx:482`) | **Settings → Open a shared trace** (2026-08-16) | +| `Read this session's trace` on every agent row | **reader mode** — a session's own history is a mode of its pane (done, #86) | +| `Share this session` on every agent row | **pane header → `i` → Share** (done, #86) | +| A `trace` **session row per read** (`App.tsx:openTrace`) | **gone**, with `openTrace` itself — no duplicate rows (done, #86) | +| Trace row's `Share` | **trace pane header** — imported traces keep a pane (done, #86) | +| Trace row's `Handover` | **trace pane header**, beside Share. Not the reader's `i`: that panel is `TraceInfo`, which a trace pane does not use (done, #86) | +| Quick-add **Trace** = open a shared dataset | **Settings → Open a shared trace** (2026-08-16) | +| `Start` | the row's own click already opened the pane (done, #86) | +| `Stop` | **nowhere, deliberately.** An idle CLI costs nothing; a runaway one is interrupted in its pane, where Ctrl-C carries the CLI's own semantics (done, #86) | +| `Delete` | **the archived view only** — archive a session, then remove it; the server enforces it (done, #86) | The last row was `stays` until the operator asked for the sidebar's trace widget to go (improv.md, iteration two). It moved rather than went away, because it is the one trace @@ -493,8 +496,10 @@ dataset id, which is furniture for something you do when a link arrives. What disappears is the *local* trace pane — a session's own history is now a mode of its own pane, not a second entity. -Agent rows keep stop/play and delete. Three glyphs less per row, which is most visible exactly -where the sidebar is worst: on a phone. +An agent row is now **one** control: `×`, which archives — it stops the agent and files it +away, and it is the only route to deleting one. A remote agent keeps its reconnect/disconnect +pair beside it, because that is a line to another machine rather than a local process. Up to +four glyphs less per row, which is most visible exactly where the sidebar is worst: on a phone. --- @@ -585,7 +590,7 @@ the block model already supports. - One component to style, so "the overview is more pleasant" becomes true everywhere at once. - The card answers "what did it actually do?" without leaving the Overview. - A session's history is reachable from the session, not from a second sidebar row. -- Three glyphs less per sidebar row, and a full-screen card on a phone. +- A sidebar row down to one control, and a full-screen card on a phone. - Removed code: the `turnsLog` stepper in the card, `openTrace`'s pane-creation path, two sidebar buttons, `.tv-badge` and the terminal-styled viewer chrome. @@ -668,11 +673,13 @@ Built (this branch): 5. Tests for the one piece of judgement in the renderer — what counts as the answer, and what stays in the work (`web/test/exchanges.test.mjs`, `npm test` in `web/`). +7. The sidebar lost its trace buttons and `openTrace`, and the row came down to one control + (#86, §3.4). Share moved into the pane header's `i` with #84. + Not yet, in the order I would do it: 6. `head.prompts[]` (§5) — index, first line and timestamp per prompt, so a surface can draw the skeleton and label "show previous turn" before fetching the page that holds it. -7. The sidebar loses its trace buttons and `openTrace`; share moves into the pane header (§3.4). 8. Windowing by exchange in reader mode: a collapsed turn is 2–3 rows, so the DOM stays small, `head.prompts[]` gives the skeleton up front, and only an opened turn needs its page. The measured-height machinery in `TraceView` is reused as-is — what changes is what a "row" means. diff --git a/server/package.json b/server/package.json index 9cddacd..52bbc51 100644 --- a/server/package.json +++ b/server/package.json @@ -16,7 +16,7 @@ "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs", "test:screenshots": "node screenshot-input.test.mjs", "test:mobile": "node mobile.test.mjs", - "test": "node test/trace-download.test.mjs && node test/attachments.test.mjs && node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs" + "test": "node test/archive.test.mjs && node test/trace-download.test.mjs && node test/attachments.test.mjs && node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs" }, "engines": { "node": ">=20.19" diff --git a/server/reader-info.test.mjs b/server/reader-info.test.mjs index 5c8d69e..2b26dba 100644 --- a/server/reader-info.test.mjs +++ b/server/reader-info.test.mjs @@ -6,7 +6,7 @@ * including the two that were title attributes a phone cannot open; * - it closes on Escape and on a press outside it; * - it offers the transcript as a file, and that URL really answers; - * - it offers Share, which is the same dialog the sidebar opens; + * - it offers Share, which is the only place that dialog opens from now; * - the sidebar no longer carries a trace widget, and Settings does. * * Set READER_INFO_PUBLIC_DIR to a prebuilt web/dist to skip the build, and @@ -310,7 +310,8 @@ try { check('a press outside closes the panel', await waitFor(async () => await page.locator('.tinfo').count() === 0, 2_000)); - // 6. Share, from the reader — the same dialog the sidebar row opens. + // 6. Share, from the reader. The sidebar row used to open this dialog too; + // since #86 the panel is the one place it comes from. await page.locator('.tinfo-btn').tap(); await page.locator('.tinfo-actions button', { hasText: 'Share' }).tap(); const shareOpen = await waitFor(async () => await page.locator('.share-card').isVisible(), 5_000); @@ -375,6 +376,17 @@ try { check('the sidebar has no trace widget left', await page.locator('.quick-add button', { hasText: 'Trace' }).count() === 0 && await page.locator('.open-trace').count() === 0); + // …and the row is down to one control. Read-trace and Share left it because + // the panel above now holds both; start and stop left it because the row + // itself opens the pane and Ctrl-C interrupts a runaway better than a button. + // What remains is archive, which is also the only route to deleting a + // session. (docs/conversation-view.md §3.4) + const rowActions = page.locator('.sidebar .row[title^="reader-info-e2e"]') + .first().locator('.row-actions button'); + check('the session row carries exactly one control', await rowActions.count() === 1, + `titles: ${JSON.stringify(await rowActions.evaluateAll((bs) => bs.map((b) => b.title)))}`); + check('and that control archives', + /archive/i.test(await rowActions.first().getAttribute('title') || '')); await page.locator('.sidebar .set-btn, .sidebar button[title="Settings"]').first().click(); const traceRow = page.locator('.setting-row', { hasText: 'Open a shared trace' }); await traceRow.waitFor({ state: 'visible', timeout: 10_000 }); diff --git a/server/src/index.js b/server/src/index.js index 0120e99..5f0f16d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -2173,6 +2173,35 @@ app.put('/api/sessions/:id', (req, res) => { res.json(store.update(existing.id, { name: name.trim() })); }); +// ---------- archiving ---------- +// +// Archiving is how a session leaves the working list, and it is STORED rather +// than derived. The idle window (`archive.after`) measures an absence of +// activity; "I am finished with this one" cannot be expressed that way — an +// agent you retire the moment it answers is as active as it will ever be. +// +// The two roads meet in the sidebar's archived view, but they are not the same +// road: the window's verdict changes when the setting changes, and this one +// does not. Only this one unlocks delete — see the DELETE route below. +app.post('/api/sessions/:id/archive', (req, res) => { + const s = store.get(req.params.id); + if (!s) return res.status(404).json({ error: 'not found' }); + // Archiving stops the agent. Putting a session away while its CLI keeps + // running is how you end up paying for work behind a row you can no longer + // see. A remote agent has no process here — its connection is a separate + // control that stays where it is, so archiving one only files it away. + if (!isRemote(s.cli) && !PASSIVE_CLIS.includes(s.cli)) stop(s.id); + res.json(store.update(s.id, { archivedAt: new Date().toISOString() })); +}); + +// Restore. Deliberately does NOT start the agent again: unarchiving says "I +// want to see this again", and starting is what opening the pane does. +app.post('/api/sessions/:id/unarchive', (req, res) => { + const s = store.get(req.params.id); + if (!s) return res.status(404).json({ error: 'not found' }); + return res.json(store.update(s.id, { archivedAt: undefined })); +}); + app.post('/api/sessions/:id/stop', (req, res) => { const s = store.get(req.params.id); if (!s) return res.status(404).json({ error: 'not found' }); @@ -2415,6 +2444,22 @@ app.put('/api/trace/:id/source', (req, res) => { app.delete('/api/sessions/:id', async (req, res) => { const s = store.get(req.params.id); if (!s) return res.status(404).json({ error: 'not found' }); + // Delete is an archived-only action: retire a session, then remove it. That + // is what keeps the one destructive control out of the working list, and it + // is enforced here rather than only in the sidebar so the rule holds for any + // caller. Being quiet for a month is NOT this flag — see the archive route. + // + // #76 adds `?ifNeverStarted=1` for the wrong-CLI mistake: a session that has + // never run has nothing worth archiving and should go in one step. When that + // lands, the exemption belongs on this guard and `everStarted` already + // carries the answer: + // if (!s.archivedAt && !(req.query.ifNeverStarted === '1' && !s.everStarted)) … + if (!s.archivedAt) { + return res.status(409).json({ + error: 'archive this session before deleting it', + code: 'not-archived', + }); + } stop(s.id); // Close the agent's poll and drop the in-memory log, so a pane later created // with the same name reads the folder fresh instead of inheriting a ghost. diff --git a/server/test/archive.test.mjs b/server/test/archive.test.mjs new file mode 100644 index 0000000..ba9b6fa --- /dev/null +++ b/server/test/archive.test.mjs @@ -0,0 +1,114 @@ +// Archiving, and the rule that delete hangs off it. +// +// The point of the feature is that one destructive action is reachable from +// exactly one place: you retire a session, and only then can you remove it. +// That rule is enforced here rather than only in the sidebar, so this drives +// the API directly — a caller that is not the UI has to meet it too. +// +// Run with: node test/archive.test.mjs +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const PORT = 7894; +const API = `http://localhost:${PORT}`; +const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-')); + +let pass = 0; let fail = 0; +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`); + ok ? pass++ : fail++; +}; + +// Test servers must not publish skills over a live Space (same strip the other +// boot suites use). +const { SPACE_ID, AM_DISTRIBUTE_SKILLS, ...BASE_ENV } = process.env; +const srv = spawn('node', ['src/index.js'], { + env: { ...BASE_ENV, PORT: String(PORT), DATA_DIR, AM_BASHRC: '/nonexistent', AM_ALLOW_MISSING_ORIGIN: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let log = ''; +srv.stdout.on('data', (d) => { log += d; }); +srv.stderr.on('data', (d) => { log += d; }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const api = async (route, init = {}) => { + const sep = route.includes('?') ? '&' : '?'; + const r = await fetch(`${API}${route}${sep}from=operator`, { + headers: { 'content-type': 'application/json' }, ...init, + }); + let body = null; + try { body = await r.json(); } catch { /* empty */ } + return { status: r.status, body }; +}; +const mkSession = async (name, cli = 'shell') => + (await api('/api/sessions', { method: 'POST', body: JSON.stringify({ name, cli, path: name }) })).body; +const sessionOf = async (id) => + (await api('/api/tree')).body.sessions.find((s) => s.id === id); + +try { + for (let i = 0; i < 120; i++) { + if (await fetch(`${API}/api/health`).then((r) => r.ok).catch(() => false)) break; + await sleep(250); + } + + // ---- delete is gated on archiving ---- + const a = await mkSession('to-delete'); + const refused = await api(`/api/sessions/${a.id}`, { method: 'DELETE' }); + check('a live session cannot be deleted', refused.status === 409, `status ${refused.status}`); + check('and the refusal says why, in words the UI can show', + /archive/i.test(refused.body?.error || ''), JSON.stringify(refused.body)); + check('the refusal carries a code', refused.body?.code === 'not-archived'); + check('and the session is still there', !!(await sessionOf(a.id))); + + const archived = await api(`/api/sessions/${a.id}/archive`, { method: 'POST' }); + check('archiving succeeds', archived.status === 200, `status ${archived.status}`); + check('and stamps when it happened', !!archived.body?.archivedAt); + + const deleted = await api(`/api/sessions/${a.id}`, { method: 'DELETE' }); + check('an archived session deletes', deleted.status === 200, `status ${deleted.status}`); + check('and is gone from the tree', !(await sessionOf(a.id))); + + // ---- the flag survives a restart, which is the whole reason it is stored ---- + const b = await mkSession('survivor'); + await api(`/api/sessions/${b.id}/archive`, { method: 'POST' }); + const persisted = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'sessions.json'), 'utf8')); + const onDisk = (Array.isArray(persisted) ? persisted : persisted.sessions || []).find((s) => s.id === b.id); + check('the archive is written to disk, not held in a browser', !!onDisk?.archivedAt); + + // ---- restore ---- + const restored = await api(`/api/sessions/${b.id}/unarchive`, { method: 'POST' }); + check('unarchiving succeeds', restored.status === 200, `status ${restored.status}`); + check('and clears the stamp', !(await sessionOf(b.id))?.archivedAt); + const refusedAgain = await api(`/api/sessions/${b.id}`, { method: 'DELETE' }); + check('so it is undeletable again', refusedAgain.status === 409, `status ${refusedAgain.status}`); + + // ---- archiving stops the agent ---- + const c = await mkSession('running-one'); + // Opening a terminal socket is what starts the process; the resize suite does + // the same. A shell is enough — the point is that something is running. + const ws = new WebSocket(`ws://localhost:${PORT}/ws?session=${c.id}&cols=80&rows=24`); + await new Promise((r) => { ws.onopen = r; ws.onerror = r; }); + await sleep(1200); + const before = await sessionOf(c.id); + check('the agent is running before archiving', before?.running === true, `state ${before?.state}`); + await api(`/api/sessions/${c.id}/archive`, { method: 'POST' }); + await sleep(600); + const after = await sessionOf(c.id); + check('archiving stopped it', after?.running === false, `state ${after?.state}`); + check('and `stopped` is still the state it lands in', after?.state === 'stopped', `state ${after?.state}`); + try { ws.close(); } catch { /* already gone */ } + + // ---- unknown ids ---- + const missing = await api('/api/sessions/nope-nope/archive', { method: 'POST' }); + check('archiving something that does not exist is a 404', missing.status === 404, `status ${missing.status}`); +} catch (e) { + check(`suite threw: ${e && e.message}`, false, log.slice(-600)); +} finally { + srv.kill(); + try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch { /* tmp */ } +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/web/src/App.tsx b/web/src/App.tsx index 02a4578..8fa28bb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -132,10 +132,18 @@ export default function App() { return s === 'prompt' || s === 'answer' ? s : 'manual'; }); const setOvSort = (v: OverviewSort) => { setOvSortRaw(v); writeStored('am-ov-sort', v); }; - // Archiving: sessions quiet for longer than the configured window are hidden - // from the sidebar and overview unless "archived" is checked. Derived, never - // stored — flipping the setting instantly (un)archives. + // Archiving takes two roads into the same view, and they are not the same + // thing. The operator archives a session deliberately (`archivedAt` on the + // record, server-side, and the agent is stopped); separately, a session quiet + // for longer than the configured window is hidden the way it always was — + // derived, so flipping the setting instantly (un)archives it again. + // Both are hidden unless "archived" is checked. Only the stored one can be + // deleted, which is why the two sets stay distinguishable below. const [showArchived, setShowArchived] = useState(false); + // A trace pane asking to be continued in a new agent. The prefilled create + // panel belongs to the sidebar, so the request is passed there and cleared + // once it has been picked up. + const [handoverFor, setHandoverFor] = useState(null); const [archiveAfter, setArchiveAfter] = useState<'week' | 'month' | 'never'>('month'); // Hiding a group from the Overview is a standing choice and lives on the server // (tree.hidden). REVEALING it is a glance, so that half stays here and resets on @@ -462,18 +470,33 @@ export default function App() { if (settingsOpen) return; api.getConfig().then((c) => setArchiveAfter(c.archive?.after ?? 'month')).catch(() => {}); }, [settingsOpen]); - const archivedIds = useMemo(() => { + // Road one: the operator said so. The server holds it, so it survives a + // reload and means the same thing on every device. + const retiredIds = useMemo( + () => new Set(tree.sessions.filter((s) => s.archivedAt).map((s) => s.id)), + [tree.sessions], + ); + // Road two: quiet for longer than the window. Unchanged, and still derived — + // it is a statement about the clock, so it has to be recomputed against the + // clock rather than written down once. + const quietIds = useMemo(() => { const out = new Set(); if (archiveAfter === 'never') return out; const cut = Date.now() - (archiveAfter === 'week' ? 7 : 30) * 864e5; for (const s of tree.sessions) { // Shells and passive panels have no trace clock — never archive them. if (s.cli === 'shell' || isPassive(s.cli) || s.state === 'working') continue; + if (s.archivedAt) continue; // already on road one const last = ages[s.id] || Date.parse(s.createdAt) || 0; if (last && last < cut) out.add(s.id); } return out; }, [tree.sessions, ages, archiveAfter]); + // What the sidebar and overview leave out of the working list. + const archivedIds = useMemo( + () => new Set([...retiredIds, ...quietIds]), + [retiredIds, quietIds], + ); // What the operator hid from the Overview, as refs (`g:` / `s:`). The // server owns the list; this is just the shape the sidebar wants for a lookup. @@ -702,12 +725,24 @@ export default function App() { const renameGroup = (id: string, name: string) => api.renameGroup(id, name).then(refresh).catch(showErr('Couldn’t rename')); const renameSession = (id: string, name: string) => { if (name.trim()) api.renameSession(id, name.trim()).then(refresh).catch(showErr('Couldn’t rename')); }; const deleteGroup = (id: string) => api.deleteGroup(id).then(() => { if (activeRef === `g:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the group')); - const stopSession = (id: string) => api.stopSession(id).then(refresh).catch(showErr('Couldn’t stop the agent')); + // Archiving stops the agent server-side, so there is no separate stop call + // left in the UI — `api.stopSession` stays for the archive route's own use + // and for anything that needs to end a process without filing it away. + const archiveSession = (id: string) => api.archiveSession(id) + .then(() => { if (activeRef === `s:${id}`) setActiveRef(null); closePane(id); refresh(); }) + .catch(showErr('Couldn’t archive that agent')); + const unarchiveSession = (id: string) => api.unarchiveSession(id).then(refresh) + .catch(showErr('Couldn’t restore that agent')); // A remote agent has no process: "stopped" is a closed connection, so the // sidebar's stop/play pair disconnects and reconnects instead. const setRemotePaused = (id: string, paused: boolean) => api.setRemotePaused(id, paused).then(refresh).catch(showErr(paused ? 'Couldn’t disconnect' : 'Couldn’t reconnect')); - const deleteSession = (id: string) => api.deleteSession(id).then(() => { if (activeRef === `s:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the agent')); + // The server refuses to delete anything that has not been archived, and says + // so in words worth passing on — a generic toast here would leave the reader + // guessing at a rule the UI is meant to be teaching. + const deleteSession = (id: string) => api.deleteSession(id) + .then(() => { if (activeRef === `s:${id}`) setActiveRef(null); refresh(); }) + .catch((e: unknown) => showErr(e instanceof Error && e.message ? e.message : 'Couldn’t delete the agent')(e)); const shareTrace = async (id: string) => { const pane = sessById[id]; if (!pane || pane.cli !== 'trace') return; @@ -750,26 +785,6 @@ export default function App() { setPage(0); if (isMobile) setMobileStage(true); }; - // Read an agent's own transcript in a read-only trace pane. The pane is a - // session record like any other (so it survives reload, tiles, and drag), and - // it's REUSED per source — clicking Trace twice reopens the same pane instead - // of littering the sidebar with duplicates. - const openTrace = async (sid: string) => { - const src = sessById[sid]; - if (!src) return; - const existing = tree.sessions.find((p) => p.cli === 'trace' && p.traceSource?.kind === 'session' && p.traceSource.ref === sid); - if (existing) { openSession(existing.id, tree.groups.find((g) => g.sessionIds.includes(existing.id))?.id); return; } - try { - // '.' = the workspaces root: a trace pane reads a transcript, so it owns no - // folder and must not create one. - const pane = await api.createSession(`Trace: ${src.name}`, 'trace', undefined, '.'); - await api.setTraceSource(pane.id, 'session', sid); - await refresh(); - setActiveRef(`s:${pane.id}`); - setPage(0); - if (isMobile) setMobileStage(true); - } catch (e) { showErr('Couldn’t open the trace')(e); } - }; // Someone shared a session as a Hub dataset: pull it down, then open a pane on // it. Errors propagate so the sidebar can show the server's own reason inline // (wrong id, no access, not a share) instead of a generic toast. @@ -965,6 +980,10 @@ export default function App() { dragId={canDrag ? `p:${s.id}` : undefined} onDragActive={setPaneDrag} onFocus={() => setFocusedId(s.id)} + onShare={() => shareTrace(s.id)} + // The prefilled create panel lives in the sidebar, so the + // request travels there rather than the panel moving here. + onHandover={() => setHandoverFor(s.id)} onClose={() => closePane(s.id)} /> ))} @@ -1049,16 +1068,16 @@ export default function App() { onActivate={activate} onOpenSession={openSession} onNewSession={newSession} - onShareSession={setShareId} - onShareTrace={shareTrace} onTraceHandover={api.getTraceLocation} - onOpenTrace={openTrace} + handoverFor={handoverFor} + onHandoverHandled={() => setHandoverFor(null)} onNewGroup={newGroup} onRenameGroup={renameGroup} onRenameSession={renameSession} onDeleteGroup={deleteGroup} onSetRemotePaused={setRemotePaused} - onStopSession={stopSession} + onArchiveSession={archiveSession} + onUnarchiveSession={unarchiveSession} onDeleteSession={deleteSession} onMove={doMove} onDragState={setSessionDrag} @@ -1067,6 +1086,7 @@ export default function App() { onToggleTheme={toggleTheme} onQuickStart={quickStart} archived={archivedIds} + retired={retiredIds} showArchived={showArchived} onToggleArchived={() => setShowArchived((v) => !v)} overviewHidden={hiddenRefs} diff --git a/web/src/api.ts b/web/src/api.ts index 13782ed..0074ba5 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -47,8 +47,18 @@ export const listFolders = (p = ''): Promise<{ path: string; folders: string[] } export const stopSession = (id: string) => fetch(`/api/sessions/${id}/stop`, { method: 'POST' }).then(json); +// Put a session away: it stops, and it leaves the working list. The server +// refuses to delete anything that has not been through here first. +export const archiveSession = (id: string) => + fetch(`/api/sessions/${id}/archive`, { method: 'POST' }).then(json); + +export const unarchiveSession = (id: string) => + fetch(`/api/sessions/${id}/unarchive`, { method: 'POST' }).then(json); + +// Keeps the server's own words: refusing to delete a session that is not +// archived is an ordinary, explainable answer, not a failure. export const deleteSession = (id: string) => - fetch(`/api/sessions/${id}`, { method: 'DELETE' }).then(json); + fetch(`/api/sessions/${id}`, { method: 'DELETE' }).then(jsonOrError); export const renameSession = (id: string, name: string) => fetch(`/api/sessions/${id}`, { method: 'PUT', headers: HEADERS, body: JSON.stringify({ name }) }).then(json); diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 9522718..e9cdcf1 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { Cli, MoveTarget, Group, Session, Tree } from '../types'; -import { STATE_LABEL, REMOTE_STATE_LABEL, isPassive, isRemote, isShareable } from '../types'; +import { STATE_LABEL, REMOTE_STATE_LABEL, isPassive, isRemote } from '../types'; import { hiddenSessionIds } from '../lib/overviewHidden'; import Logo from './Logo'; import NewSession from './NewSession'; @@ -10,7 +10,7 @@ import { filesFromTransfer, pendingAttachmentsFromFiles, revokePendingAttachments, transferMayContainFile, } from '../lib/attachments'; import type { PendingAttachment } from '../lib/attachments'; -import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph, PlayGlyph, GridGlyph, PlusGlyph, AmMark, ShareGlyph, HandoverGlyph, ListGlyph, EyeGlyph, EyeOffGlyph } from './icons'; +import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph, PlayGlyph, GridGlyph, PlusGlyph, AmMark, EyeGlyph, EyeOffGlyph } from './icons'; import { dropZone, backgroundAnchor, isBackgroundTarget } from './sidebar-dnd'; import type { Zone, Kind } from './sidebar-dnd'; @@ -37,8 +37,8 @@ const fmtAgo = (ts?: number) => { export default function Sidebar({ clis, tree, activeRef, focusedId, defaultPath, ages, onActivate, onOpenSession, onNewSession, onNewGroup, onRenameGroup, onRenameSession, onDeleteGroup, - onStopSession, onSetRemotePaused, onDeleteSession, onShareSession, onShareTrace, onTraceHandover, onOpenTrace, onMove, onDragState, onOpenSettings, theme, onToggleTheme, onQuickStart, - archived, showArchived, onToggleArchived, + onArchiveSession, onUnarchiveSession, onSetRemotePaused, onDeleteSession, onTraceHandover, handoverFor, onHandoverHandled, onMove, onDragState, onOpenSettings, theme, onToggleTheme, onQuickStart, + archived, retired, showArchived, onToggleArchived, overviewHidden, onToggleOverviewHidden, }: { clis: Cli[]; @@ -55,19 +55,28 @@ export default function Sidebar({ onRenameGroup: (id: string, name: string) => void; onRenameSession: (id: string, name: string) => void; onDeleteGroup: (id: string) => void; - onStopSession: (id: string) => void; + // Archiving stops the agent and takes it out of the working list; it is + // also the only route to deleting one (the server enforces that). + onArchiveSession: (id: string) => void; + onUnarchiveSession: (id: string) => void; onSetRemotePaused: (id: string, paused: boolean) => void; onDeleteSession: (id: string) => void; - onShareSession: (id: string) => void; - onShareTrace: (id: string) => void; + // Continuing from a trace is triggered on the trace's own pane now, but the + // prefilled create panel it opens lives here — so the request arrives as an + // id, and is handed back once this has acted on it. onTraceHandover: (id: string) => Promise<{ path: string; sessionId?: string | null }>; - onOpenTrace: (id: string) => void; + handoverFor: string | null; + onHandoverHandled: () => void; onMove: (ref: string, to: MoveTarget) => void; onDragState?: (ref: string | null) => void; // lets the stage offer per-tile drop targets theme: 'light' | 'dark'; onToggleTheme: () => void; onQuickStart: (cli: string, prompt: string, name?: string, path?: string, attachmentOptions?: QuickStartAttachmentOptions) => Promise; + // Everything kept out of the working list, by either road… archived: Set; + // …and the subset the operator archived on purpose. Only these can be + // deleted; the rest are merely quiet and are offered the archive instead. + retired: Set; showArchived: boolean; onToggleArchived: () => void; // Refs hidden from the OVERVIEW. The sidebar keeps showing them — it is where @@ -156,6 +165,16 @@ export default function Sidebar({ }); }; + // A trace pane asked to be continued in a new agent: open the create panel + // prefilled, exactly as the old per-row button did, then release the request + // so re-opening it later fires again. + useEffect(() => { + if (!handoverFor) return; + const s = sessById[handoverFor]; + onHandoverHandled(); + if (s) openHandover(s); + }, [handoverFor]); + const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); }; // Archived sessions vanish from the tree unless the legend checkbox is on. const isHidden = (id: string) => !showArchived && archived.has(id); @@ -360,28 +379,37 @@ export default function Sidebar({ {s.name} )} {fmtAgo(ages?.[s.id])} + {/* One button on a live row, and it files the agent away. Start was the + row's own onClick spelled twice; stop went because an idle CLI costs + nothing and a runaway one is interrupted in its pane, where Ctrl-C + has the CLI's own semantics. A remote agent keeps its connection + pair: that is a line to another machine, not a local process. */} - {s.cli === 'trace' ? ( - <> - - - - ) : isRemote(s.cli) ? ( - // No process to kill: stop/play are disconnect/reconnect, and - // "reconnect" must not try to open a terminal for this pane. + {isRemote(s.cli) && ( s.remote?.paused ? : - ) : s.running - ? - : } - {isShareable(s.cli) && ( - <> - - - )} - + {showArchived && archived.has(s.id) ? ( + // The archived view, where the two roads show themselves. A session + // the operator retired can come back or be removed; one that is + // merely quiet has not been decided about, so it is offered the + // decision rather than the delete. + retired.has(s.id) ? ( + <> + + + + ) : ( + + ) + ) : ( + + )} ); diff --git a/web/src/components/TracePane.tsx b/web/src/components/TracePane.tsx index 30ead7a..004db2b 100644 --- a/web/src/components/TracePane.tsx +++ b/web/src/components/TracePane.tsx @@ -21,7 +21,7 @@ import type { TraceBlock, TraceTurn } from '../api'; import { useTraceWindows, type TraceHeadInfo, type TraceSource } from '../lib/traceWindows'; import { renderMarkdown } from '../lib/markdown'; import Logo from './Logo'; -import { CloseGlyph } from './icons'; +import { CloseGlyph, ShareGlyph, HandoverGlyph } from './icons'; const ROW_EST = 44; // unmeasured row height, collapsed const OVERSCAN_PX = 600; @@ -569,7 +569,7 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', live, onHead, o } export default function TracePane({ - session, focused, zoom = 100, dragId, sourceLive, onDragActive, onFocus, onClose, + session, focused, zoom = 100, dragId, sourceLive, onDragActive, onFocus, onShare, onHandover, onClose, }: { session: Session; focused?: boolean; @@ -579,6 +579,12 @@ export default function TracePane({ dragId?: string; onDragActive?: (dragging: boolean) => void; onFocus?: () => void; + /** Publish this trace, and continue from it in a new agent. Both used to be + * buttons on the sidebar row; they belong to the trace, so they live on the + * pane that shows it. (The reader's `i` panel is ConversationView's, and a + * trace pane does not use ConversationView.) */ + onShare?: () => void; + onHandover?: () => void; onClose: () => void; }) { const [head, setHead] = useState(null); @@ -629,6 +635,14 @@ export default function TracePane({ value={query} onChange={(e) => setQuery(e.target.value)} /> + {onShare && ( + + )} + {onHandover && ( + + )} diff --git a/web/src/types.ts b/web/src/types.ts index 53c784a..f7d98c1 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -11,6 +11,10 @@ export interface Session { everStarted: boolean; running: boolean; state: SessionState; + // Set when the operator archived this session — a stored decision, not the + // idle window's verdict, which is computed in App.tsx and expires when the + // setting changes. Only a session with this can be deleted (server-enforced). + archivedAt?: string; // Only on `cli: 'trace'` panes: what the read-only trace view is pointed at. // A regular agent session needs no such record — it reads its own transcript. traceSource?: { kind: 'session' | 'bundle'; ref: string } | null; From e1e234195c100afa979d54241a8b09b05a1ec664 Mon Sep 17 00:00:00 2001 From: Agent Manager Date: Tue, 18 Aug 2026 14:43:19 +0000 Subject: [PATCH 2/3] Teardown goes through the same door: archive, then delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `screenshot-input.test.mjs` clears the session list twice by calling DELETE directly. Since this branch the server refuses to delete a session that has not been retired, so both loops silently 409'd and left their fixtures behind — the "only a stopped target" and "discards across reload" checks then counted sessions that should have been gone. Both are teardowns of sessions that HAVE started, so `?ifNeverStarted=1` is not their route; they archive first, exactly as the sidebar does. The suite is back to 31/31, matching what #76 scores on its own. Worth knowing beyond this file: the guard applies to every caller, not just the UI, so anything that deletes a session programmatically now archives it first. `migration.test.mjs` has three such cleanup calls; they are `.catch(() => {})` with nothing asserted after them, so they no-op harmlessly in a throwaway DATA_DIR and the suite stays green. Co-Authored-By: Claude Opus 5 --- server/screenshot-input.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/screenshot-input.test.mjs b/server/screenshot-input.test.mjs index 5fae6e3..fe6b9c6 100644 --- a/server/screenshot-input.test.mjs +++ b/server/screenshot-input.test.mjs @@ -522,6 +522,11 @@ try { await page.close(); const existing = await (await fetch(`${API}/api/sessions`)).json(); for (const session of existing) { + // Archive first: since #86 the server refuses to delete a session that has + // not been retired, so a bulk teardown has to go through the same door the + // sidebar does. These fixtures have started, so `?ifNeverStarted=1` is not + // the route for them — that one is for a session that never ran. + await fetch(`${API}/api/sessions/${session.id}/archive`, { method: 'POST' }); await fetch(`${API}/api/sessions/${session.id}`, { method: 'DELETE' }); } const freshContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); @@ -568,6 +573,9 @@ try { check('conditional cleanup cannot delete a target that has started', conditionalDelete.response.status === 409 && afterConditionalDelete.some((session) => session.id === retained.body.id)); + // Same reason as the teardown above: this one has started, so it is archived + // before it can be removed. + await fetch(`${API}/api/sessions/${retained.body.id}/archive`, { method: 'POST' }); await fetch(`${API}/api/sessions/${retained.body.id}`, { method: 'DELETE' }); await freshContext.close(); } finally { From 0e798da45f0e8d53cf423c9c83673313f78f438e Mon Sep 17 00:00:00 2001 From: Agent Manager Date: Tue, 18 Aug 2026 16:31:43 +0000 Subject: [PATCH 3/3] A trash can for delete, because it is not the same act as archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the operator on dev: the delete in the archived view should not be another `×`. They are right, and the reason is the point. On a working row `×` now means **archive**, which is reversible; reusing that mark for **delete**, which is not, dresses the permanent action as the recoverable one. Two acts, two marks. It is the `TrashGlyph` the Files pane and the skills editor already use, so destructive looks the same wherever it appears rather than being invented again here. Two things found while checking the rest of the row reads correctly: **Restore was a ▷.** On this very row that glyph already means *reconnect* for a remote agent, so an archived remote showed two identical triangles meaning different things — and ▷ promises a start that unarchiving deliberately does not do. It is an arrow back up into the list now. **`.danger-hover` was never defined.** I referenced it on the delete button in the original PR and no rule existed, so the destructive control had no destructive affordance at all. It now turns red under the pointer, mirroring `.tw-act.danger` — the idiom the Files pane's row trash already uses — rather than `.mini-btn.danger`, which is the filled red button for a surface whose whole point is the action. Every control in the row now carries an `aria-label` that names the act: Archive, Restore, Delete. None of them says "close". Presentation and labelling only. Archive is still the row's one control, delete still lives only in the archived view, and a never-started session still deletes in one step. Co-Authored-By: Claude Opus 5 --- web/src/components/Sidebar.tsx | 23 ++++++++++++++++------- web/src/styles.css | 5 +++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 83272d2..c79c987 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -10,7 +10,7 @@ import { pendingAttachmentsFromFiles, revokePendingAttachments, transferMayContainFile, uploadPendingAttachments, } from '../lib/attachments'; import type { PendingAttachment } from '../lib/attachments'; -import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph, PlayGlyph, GridGlyph, PlusGlyph, AmMark, EyeGlyph, EyeOffGlyph } from './icons'; +import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph, PlayGlyph, UpGlyph, TrashGlyph, GridGlyph, PlusGlyph, AmMark, EyeGlyph, EyeOffGlyph } from './icons'; import { dropZone, backgroundAnchor, isBackgroundTarget } from './sidebar-dnd'; import type { Zone, Kind } from './sidebar-dnd'; @@ -493,19 +493,28 @@ export default function Sidebar({ // the operator retired can come back or be removed; one that is // merely quiet has not been decided about, so it is offered the // decision rather than the delete. + // + // Deleting is the one irreversible thing here, so it does NOT reuse + // the `×` that means archive on the row above — a reversible action + // and a permanent one must not share a mark. It is the same trash + // the Files pane and the skills editor use, so destructive looks the + // same wherever it appears. Restore is an arrow back up into the + // list rather than a ▷, which on this very row already means + // "reconnect" for a remote agent — and which would in any case + // promise a start that unarchiving deliberately does not do. retired.has(s.id) ? ( <> - - + + ) : ( - ) ) : ( - )} diff --git a/web/src/styles.css b/web/src/styles.css index a30e830..03c6f6b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1056,6 +1056,11 @@ body { .files-new.err { color: var(--danger); } .tw-confirm { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; } .tw-warn { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* A destructive ICON in a row turns red under the pointer, the way the Files + pane's trash does (.tw-act.danger). Distinct from .mini-btn.danger below, + which is the filled red button for a surface whose whole point is the + action — too loud for one glyph among others in a list row. */ +.mini-btn.danger-hover:hover { color: var(--danger); } .mini-btn.danger { background: var(--danger); color: #fff; border-color: transparent; } .mini-btn.danger:disabled { opacity: .5; } /* the one obvious action in a group — Create, Save and close */