Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions docs/conversation-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 14 additions & 2 deletions server/reader-info.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
8 changes: 8 additions & 0 deletions server/screenshot-input.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 55 additions & 1 deletion server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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.
Expand Down
Loading