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 7398c5c..c483c8c 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/input-required.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/input-required.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/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 { diff --git a/server/src/index.js b/server/src/index.js index 8726fbd..1add091 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -2186,6 +2186,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' }); @@ -2428,9 +2457,34 @@ 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' }); - if (req.query.ifNeverStarted === '1' && s.everStarted) { + // Two guards, answering two different questions, and a session has to satisfy + // whichever one it is being asked. + // + // `?ifNeverStarted=1` is the caller saying "only if this never ran" — the + // wrong-CLI mistake, where an agent created by accident is abandoned before it + // has done anything. That is a precondition the CALLER set, so a session that + // has started is refused even though it might have been deletable without the + // flag, and refused in its own words. + // + // Otherwise 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. + // + // The two compose into the exemption this guard was written expecting: a + // session that never ran has nothing worth archiving, so `ifNeverStarted=1` + // gets it in one step instead of two. + const claimedNeverStarted = req.query.ifNeverStarted === '1'; + if (claimedNeverStarted && s.everStarted) { return res.status(409).json({ error: 'session has already started' }); } + if (!claimedNeverStarted && !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..7639525 --- /dev/null +++ b/server/test/archive.test.mjs @@ -0,0 +1,143 @@ +// 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 */ } + + // ---- where this meets #76: deleting a session that never ran ---- + // The wrong-CLI mistake. `?ifNeverStarted=1` is the caller's own + // precondition, so it answers a different question from the archive guard and + // both have to keep working. + const fresh = await mkSession('never-ran'); + const oneStep = await api(`/api/sessions/${fresh.id}?ifNeverStarted=1`, { method: 'DELETE' }); + check('a session that never ran deletes in one step, no archiving first', + oneStep.status === 200, `status ${oneStep.status}`); + check('and it is gone', !(await sessionOf(fresh.id))); + + // The same flag on a session that HAS run is refused — in #76's words, not + // the archive guard's, because the caller asked the other question. + const ran = await mkSession('has-run'); + const ws2 = new WebSocket(`ws://localhost:${PORT}/ws?session=${ran.id}&cols=80&rows=24`); + await new Promise((r) => { ws2.onopen = r; ws2.onerror = r; }); + await sleep(1200); + const startedRefusal = await api(`/api/sessions/${ran.id}?ifNeverStarted=1`, { method: 'DELETE' }); + check('a started session is refused for ifNeverStarted', + startedRefusal.status === 409, `status ${startedRefusal.status}`); + check('and in #76\'s words rather than the archive guard\'s', + /already started/i.test(startedRefusal.body?.error || ''), JSON.stringify(startedRefusal.body)); + // …and without the flag it is the archive rule that answers. + const plainRefusal = await api(`/api/sessions/${ran.id}`, { method: 'DELETE' }); + check('without the flag, the archive rule still answers', + plainRefusal.status === 409 && plainRefusal.body?.code === 'not-archived', + JSON.stringify(plainRefusal.body)); + check('so the session survives both refusals', !!(await sessionOf(ran.id))); + try { ws2.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 8dd2fc1..ed7fef8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -131,10 +131,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 @@ -461,18 +469,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. @@ -721,12 +744,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; @@ -769,26 +804,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. @@ -984,6 +999,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)} /> ))} @@ -1068,16 +1087,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} @@ -1088,6 +1107,7 @@ export default function App() { onPrepareQuickStart={prepareQuickStart} onAbandonQuickStart={abandonQuickStart} 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 e23cc5e..27511ab 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 2f616cf..c79c987 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 Logo from './Logo'; import NewSession from './NewSession'; import FolderPicker from './FolderPicker'; @@ -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, ShareGlyph, HandoverGlyph, ListGlyph, 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'; @@ -36,10 +36,10 @@ 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, + onArchiveSession, onUnarchiveSession, onSetRemotePaused, onDeleteSession, onTraceHandover, handoverFor, onHandoverHandled, onMove, onDragState, onOpenSettings, theme, onToggleTheme, onQuickStart, onPrepareQuickStart, onAbandonQuickStart, - archived, showArchived, onToggleArchived, + archived, retired, showArchived, onToggleArchived, overviewHidden, onToggleOverviewHidden, }: { clis: Cli[]; @@ -56,21 +56,33 @@ 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; + // Creating a session up front so an attachment has somewhere to go (#76). + // Nothing to do with the row's controls, which is why both survive the same + // resolution. onPrepareQuickStart: (cli: string, name?: string, path?: string) => Promise; onAbandonQuickStart: (id: string) => 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 @@ -241,6 +253,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); @@ -455,28 +477,46 @@ 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. + // + // 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/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/styles.css b/web/src/styles.css index 3a3c160..3588f0c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1057,6 +1057,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 */ diff --git a/web/src/types.ts b/web/src/types.ts index f0d9592..d08eb43 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -18,6 +18,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; /** Native CLI event says an interactive TUI dialog is currently pending. */ inputRequired?: InputRequired | null; // Only on `cli: 'trace'` panes: what the read-only trace view is pointed at.