✨ feat(sessions): delete a session from the sidebar - #4
Conversation
Code Review SummaryFor the maintainer: don't merge yet — The PR adds Verdict: BLOCKED Reviewed HEAD:
|
Add a trash action to every session row, behind a confirmation dialog, that removes the session and everything keyed to it. Archive stays the reversible path; this is the destructive one. The row's actions also collapse into one overflow menu. Three side-by-side hover icons cost about a third of the title's width in the 268px sidebar (measured: title column 146px -> 204px), and every further action would take another slice. The trigger is hover/focus-revealed on desktop and always visible on the phone, and a right-click on a row opens the same menu. DELETE /api/sessions/:sessionId destroys a live omp child first and AWAITS its exit — omp flushes session state on shutdown and would otherwise recreate the transcript just removed — then purges side questions, the transcript and its `.bak-*` rewind copies, the sibling artifacts directory, and the chamber's archived_sessions / session_ui_state / session_stream_state / queued_messages / chat_sessions rows. A session mid-run (a turn, a live subagent, or a dialog omp is blocked on) is refused with 409 rather than silently killing work; a pending `new-...` chat is refused because nothing is stored for it yet. An id that is not one path segment is refused at every entry point (route, purgeSessionData, purgeBtwForSession) via the shared fs/path-segment guard. `join(btwRoot, '.')` normalizes back to the btw root, so a `.` id deleted every session's side questions and still answered 404; the guard asserts `normalize(id) === id`, the same operation the callers' join performs. The sidebar dataset and the session-file caches are invalidated with the mutation: the TTL'd dataset would otherwise serve the deleted row back to the refresh that follows the delete.
5a2e8c2 to
e4fd65e
Compare
|
Addressed in
|
Code Review SummaryFor the maintainer: the Delta since Verdict: NEEDS_EVIDENCE Reviewed HEAD:
|
|
Screenshots attached to the PR description (finding 2). Still worth a human eye: I captured them in an environment with no vision model, so I did not look at them myself. Verified only mechanically — they decode, ink ratios are plausible (1.9%-13.7%), and each pair differs in the right region. Readability and the error strip are what the finding asks for, and only a person can settle those. They live on |
|
🎉 This PR is included in version 3.5.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
…ling
A dev server holds the whole client module graph open (`development: true`:
measured 3,545 descriptors for this app after a single page load, against 17
with `development: false`) and a macOS reload never closes the previous
generation's (bun#40706), so the count climbs with every rebuild. At Darwin's
`OPEN_MAX` (10,240) `posix_spawn` answers `EBADF: bad file descriptor,
posix_spawn '/bin/sh'` — verified A/B: 10,230 descriptors spawn fine, 10,240
fails, the PTY path included. From then on every spawn in the process fails at
once (the PTY shell, the omp child, git, rg), and the message names the shim
rather than the cause.
`lib/lifecycle/fd-pressure.ts` makes that state observable:
- `countOpenFileDescriptors()` sweeps the table with `fcntl(F_GETFD)` through
`bun:ffi` (the route `flock.ts` already takes), stops at the cliff because a
descriptor above it cannot be opened anyway, and returns null where the host
cannot answer. ~1 ms, and it never throws: the health route is a readiness
probe.
- `GET /api/health` carries `fds: { open, highest, limit, nearCliff }`, and
`ompchamber status` prints it with a restart hint — the pressure is visible
before the breakage instead of only after it.
- `describeSpawnFailure()` appends the real cause and the fix when the message
carries the symptom (`EBADF`/`EMFILE`) AND the sweep sees pressure. Both
conditions are required: an unrelated failure that happened while the table
was full, and a genuine bad-descriptor bug, must still read as themselves.
It wraps the two spawns whose failures reach the UI: the terminal's
`createSession` and the omp child in `rpc/process.ts`.
Verified: the counter agrees with `lsof` (4,385 open / highest #4,386 against
4,386 unique descriptors); a real PTY spawn at the cliff reports
`EBADF: bad file descriptor, posix_spawn '/bin/sh' — the chamber server is out
of file descriptors (10,240 of 10,240 open, highest #10,239); restart the
instance …`, while `spawn ENOENT` under the same pressure passes through
untouched; and `fds: 4385/10240` appears in `ompchamber status` and on the
running dev instance.
What changed
A session can now be deleted from the sidebar, and every row's actions moved into one overflow menu.
⋯trigger per row, replacing the side-by-side hover icons (rename / archive). Desktop reveals it on hover/focus; the mobile drawer shows it always, since touch has no hover. Right-clicking a row opens the same menu. The menu holds Rename, Archive/Unarchive, and Delete..bak-*copy an earlier rewind left beside it, its sibling subagent-artifacts directory, its side questions (rows, live child, and the private transcript copy under the btw workspace), and the chamber'sarchived_sessions/session_ui_state/session_stream_state/queued_messages/chat_sessionsrows. In mock mode the demosessionsrow and itsfilestree.new-…chat is not offered the action.Two behaviours the server enforces, not the caller:
409) — a run, a live subagent, or a dialog the agent is blocked on. Killing that would destroy work the user can still see.Deliberately left unchanged — all upstream, all kept through the merge: the desktop subagent chevron, the mobile roster chevron and
useExpandedSessions(), and the status slot whereawaitingInputoutranks the run spinner.Why
The sidebar had no way to remove a session — only Archive, which keeps the transcript and hides the row. Users who wanted a conversation actually gone had to leave the UI and delete the JSONL under
~/.omp/agent/sessionsby hand, which then left the chamber's own rows behind as orphans.The menu is part of the same ask: three side-by-side hover icons cost roughly a third of the title's width in the 268px sidebar (measured: title column 146px → 204px), and every further action would take another slice.
Surface
src/server(Bun/Elysia, omp bridge, SQLite)src/client(Preact UI)src/shared(server and client)src/cli(ompchamber serve/update/stop/status/logs)MOCK=true(runs with noompinstall)ompsessionbun run build, thenbun run startorompchamber serve --prod)Validation
src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts(4 tests, upstream) passes against the merged row.Evidence
Ran
bun run devandNODE_ENV=production bun run scripts/../src/server/index.tson Bun 1.4.2, Ubuntu 24.04 (WSL2), headless Chromium 1440×900 and 390×844. Agent dir and DB were pointed at throwaway paths so a real~/.ompwas never touched. Evidence is from HEAD5a2e8c2.Real omp session (dev mode). Deleting a row through the menu removed it from the sidebar, and
GET /api/sessions/listno longer listed it. On disk the transcript, its.bak-1copy and the sibling artifacts directory were all gone, while a second session in the same project survived. Repeating witharchived_sessions,session_ui_stateandqueued_messagesrows seeded for the id left all three empty afterwards.Production mode. Same flow against
NODE_ENV=production: the row disappeared,?sessionId=was cleared, and the transcript was gone from disk.Mock mode.
MOCK=true: 15 demo rows → 14, and thesessionsrow was gone from SQLite.Ordering. The await-before-purge rule was exercised with a stub wrapper whose
destroyAndWait()writes a valid transcript back, as omp does on shutdown: the file was still gone after the call. A busy stub was refused with409and its transcript left alone. This is a stub, not a real omp child — see the caveat below.Refusals.
new-…→ 400session_pending; an id containing..→ 400; an unknown id → 404;GET /api/sessions/:id(the workspace listing) still answers, i.e. the DELETE binding did not shadow it.Menu. Rename from the menu opens the inline editor without changing the URL; Archive persists and the label flips to Unarchive; Delete opens the dialog and Cancel is a no-op; clicking a row body still selects the session. The menu flips above its trigger when it would overflow the viewport bottom (300px viewport: trigger 185–207, menu rendered 78–181).
Padding. The
⋯trigger's ink sits 14.38px from the pill's left edge and 14.33px from its right, measured from the glyphgetBBox()and confirmed against the rendered pixels (14.4 / 13.6). Before the last commit those were 14.38 / 12.33.Risk
Destructive and not reversible from the UI. The transcript is gone; there is no trash bin. Mitigation is the confirmation dialog naming what disappears, and Archive remaining the adjacent, reversible action.
State touched: the session JSONL under
~/.omp/agent/sessions/<project>/, its sibling artifacts directory,<db dir>/btw/<sessionId>/, and the SQLite tablesarchived_sessions,session_ui_state,session_stream_state,queued_messages,chat_sessions(plussessions/filesin mock mode). A live PTY,omp_chamber_settings, and a running daemon are untouched.Partial failure. The purge is ordered so the worst crash leaves an invisible orphan rather than a broken row: if the process dies after the file is removed but before the DB rows, the session is already gone from the sidebar and only unreferenced rows remain. The reverse order would leave a session that reappears with none of its state. Two known gaps, both small and left as-is:
purgeChamberRowsruns its seven DELETEs without awithTransactionwrapper, and its table order matters only becausefilesmust precedesessions(FK is on) — the order is correct but nothing enforces it.Rollback: revert the merge commit; the API is additive (one new
DELETEroute), so nothing else depends on it.Not verified
ompcannot spawn ("No models available"). The ordering contract itself is documented onAgentSessionWrapper.destroyAndWait().Review response (review 1 — BLOCKED)
All four findings are addressed in
e4fd65e. The branch was rebuilt onmainas a single commit with an identical tree, so the emoji-prefixed subjects are
gone without changing any file content.
1. blocker —
.deleted the whole btw root: FIXED, and reproduced first.path.join(btwRoot, '.')isbtwRoot, andpurgeBtwForSessionremoved thatjoined path unconditionally, so a
.id wiped every session's side questionsand the route still answered 404. Reproduced live before the fix, against a
throwaway btw root holding two unrelated sessions' topic transcripts:
The guard is now one shared predicate (
src/server/lib/fs/path-segment.ts)applied at all three entry points — the route,
purgeSessionData, andpurgeBtwForSession— because the latter two are reachable on their own andtheir failure mode is other sessions' data. It asserts
normalize(id) === id,the same operation the callers'
joinperforms, so it proves a property of thepath that gets built rather than enumerating suspicious spellings.
After the fix, against the same fixture:
2. evidence-gap — screenshots: NOT CLOSED, and I cannot close it here.
This environment has no vision-capable model, so I can capture images but not
read them, and I will not post a screenshot I have not looked at. What I have
instead is DOM measurement and API/disk assertion, including the numbers the
finding asks about: the trigger's ink sits 14.4px / 13.6px from the pill's edges
(measured from
getBBox()and confirmed against rendered pixels), the menuflips above its trigger when it would overflow (300px viewport: trigger
185-207, menu rendered 78-181), and the mobile trigger measures right-aligned
to its row at 390px. A reviewer still needs to eyeball readability and the
error strip — that part of the finding stands.
3. non-blocker — tests: ADDED (10).
src/server/lib/fs/path-segment.test.ts(5) — the guard's accept/rejecttable, plus the property test that whenever the guard says yes,
path.join(root, id)stays directly insiderootanddirnameisroot.Two of them call
purgeBtwForSession('.')against a throwaway btw root andassert both unrelated transcripts survive.
src/server/routes/sessions/delete.test.ts(5) — the refusal branches:400
session_pending, 400 invalid id (.and four traversal shapes), 404 fora well-formed unknown id (kept distinct from 400), and 405 on a wrong verb.
The regression is real, not nominal: disabling the guard turns 2 of the 5
path-segment tests red. The route test points
PI_CODING_AGENT_DIRat an emptytemp tree so the 404 branch does not walk a real
~/.omp(35ms, down from5.46s).
4. non-blocker — emoji subjects: CONFIRMED, and fixed.
Verified with the repo's own parser rather than by reading the grammar:
You were right that
release/release-rules.jspins onlynoteKeywords/notesPatternand leavesheaderPatternat the default. Rebuilt as a singlefeat(sessions): …commit; the tree is byte-identical to the reviewed one(
git rev-parse HEAD^{tree}compared), so every measurement below still holds.Screenshots (finding 2)
Captured at HEAD
e4fd65eon a real omp session (not MOCK), Chromium, desktop1440x900 and mobile 390x844. Kept on a separate branch (
pr-assets/4-screenshots)so they do not enter this PR's diff.
Caveat, stated plainly: this environment has no vision model, so I captured
these but did not look at them. They are verified mechanically only — each
image decodes, its ink-to-background ratio is plausible (1.9%-13.7%), and each
before/after pair differs where it should (dialog → +error changes 2.4% inside
the dialog's own bounding box; mobile row → menu changes 0.8% near the row).
A human still has to confirm readability, which is exactly what the finding asks
for.
Desktop — the row and its menu
⋯revealed)Desktop — the delete dialog, and a server refusal
The refusal shot is a genuine round trip: the transcript was removed from disk
behind the already-rendered row, so the route answered 404 and the dialog
rendered it inline rather than closing.
Mobile (390px) — trigger always visible, no hover