Repository navigation
feat(ai-vault): delete a provider session from the AI Vault list - #10249
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds AI Vault session deletion with shared contracts, agent/path validation, ordered filesystem trash operations, IPC and preload APIs, cache invalidation, and stale-scan guards. The renderer gains deletion eligibility checks, action menus, confirmation dialogs, toast handling, web fallbacks, and localized strings. Scanner root helpers, OpenClaw path predicates, DROID root construction, subagent listing validation, and parse-cache entry invalidation are also added or shared. Extensive tests cover validation, deletion execution, cache behavior, IPC orchestration, and renderer interactions. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/main/ai-vault/cached-session-list.ts (1)
57-79: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick winKeep a replacement same-key scan from being cleared by its predecessor.
After invalidation, a new request can start scan B with the same key as scan A. When A settles, the existing
finallykey comparison still matches and clears B’s in-flight tracking, allowing a third caller to launch another expensive scan. Guard cleanup by request identity orstartGeneration, and add an A → invalidate → B → resolve A regression case.Proposed fix
- if (inflightKey === key) { + if (inflightKey === key && startGeneration === cacheGeneration) { inflightKey = null inflightList = null }src/main/ipc/ai-vault.ts (1)
116-123: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick winGuard the multi-host in-flight cleanup with
startGeneration.
invalidateMultiHostAiVaultListCache()now bumps and checkscacheGeneration, but the.finally()cleanup at line 124 still clearsinflightKey/inflightListfor any same-key scan. If an older scan resolves after a delete-invalidation, its cleanup can still clobber a newer same-key scan’s dedup tracking, causing an extra scan.Proposed guard
.finally(() => { // Only clear tracking if it still refers to this request: a concurrent // different-scope scan may have replaced it and must stay dedupable. - if (inflightKey === key) { + if (startGeneration === cacheGeneration && inflightKey === key) { inflightKey = null inflightList = null } })
🧹 Nitpick comments (2)
src/preload/index.ts (1)
3965-3966: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winTighten the
aiVaultpreload return types.Each
aiVaultIPC method is annotated asPromise<unknown>, whilesrc/preload/api-types.tsdeclares specific result types likeAiVaultDeleteSessionResult. This bypasses compile-time checking that the handler return shape matches the exposed API contract.♻️ Suggested fix
- deleteSession: (args: AiVaultDeleteSessionArgs): Promise<unknown> => + deleteSession: (args: AiVaultDeleteSessionArgs): Promise<AiVaultDeleteSessionResult> => ipcRenderer.invoke('aiVault:deleteSession', args),src/renderer/src/components/right-sidebar/AiVaultSessionDeleteDialog.tsx (1)
1-1: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winRef mutated during render — prefer the conditional-setState "latest value" pattern.
Static analysis (React Doctor,
no-ref-current-in-render) flagslastSessionRef.current = sessionas a render-time mutation. It's the common "keep last non-null value" idiom and is low-risk today, but writing to a ref unconditionally on every render (rather than only lazily/once) can misbehave with discarded/duplicated renders in Concurrent React. React's own docs recommend the equivalent pattern using state instead of a ref for exactly this "remember info from a previous render" case, which stays render-pure while producing the same flash-free result.♻️ Proposed fix using conditional setState instead of ref mutation
-import { useRef } from 'react' +import { useState } from 'react'- const lastSessionRef = useRef<AiVaultSession | null>(session) - if (session) { - lastSessionRef.current = session - } - const displayedSession = session ?? lastSessionRef.current + const [lastSession, setLastSession] = useState<AiVaultSession | null>(session) + if (session && session !== lastSession) { + setLastSession(session) + } + const displayedSession = session ?? lastSessionAlso applies to: 31-35
Source: Linters/SAST tools
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: e512fe41-57b3-48fc-ae55-4e3e23e95987
📒 Files selected for processing (39)
src/main/ai-vault/cached-session-list.test.tssrc/main/ai-vault/cached-session-list.tssrc/main/ai-vault/session-delete-target.test.tssrc/main/ai-vault/session-delete-target.tssrc/main/ai-vault/session-delete.test.tssrc/main/ai-vault/session-delete.tssrc/main/ai-vault/session-scanner-discovery.tssrc/main/ai-vault/session-scanner-droid-kimi-sources.tssrc/main/ai-vault/session-scanner-parse-cache.tssrc/main/ai-vault/session-scanner-root-dirs.tssrc/main/ai-vault/session-scanner-source-discovery.tssrc/main/ipc/ai-vault-delete.tssrc/main/ipc/ai-vault-subagent-list.tssrc/main/ipc/ai-vault.test.tssrc/main/ipc/ai-vault.tssrc/preload/api-types.tssrc/preload/index.tssrc/renderer/src/components/right-sidebar/AiVaultPanel.tsxsrc/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsxsrc/renderer/src/components/right-sidebar/AiVaultSessionDeleteDialog.tsxsrc/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsxsrc/renderer/src/components/right-sidebar/AiVaultSessionRow.tsxsrc/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsxsrc/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsxsrc/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsxsrc/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.tssrc/renderer/src/components/right-sidebar/ai-vault-session-deletability.tssrc/renderer/src/components/right-sidebar/ai-vault-session-delete-action.test.tsxsrc/renderer/src/components/right-sidebar/ai-vault-session-delete-action.tssrc/renderer/src/components/right-sidebar/ai-vault-session-delete-reason.test.tssrc/renderer/src/components/right-sidebar/ai-vault-session-delete-reason.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/renderer/src/web/web-preload-api.tssrc/shared/ai-vault-session-deletion.tssrc/shared/ai-vault-types.ts
|
Thanks @coderabbitai — both addressed in 1834a80:
Both are covered by regression tests (verified failing without the fix). |
|
Tip For best results, initiate chat on the files or code changes.
🐇✨ If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
|
reviewing now! |
Greptile SummaryThis PR adds a Delete action to AI Vault session rows, moving sessions to the OS trash after a confirmation dialog. It supports 12 of 16 providers (single-file and directory-shaped sessions), shows a disabled item with a tooltip for the 4 unsupported providers, and gates on live-session state, execution host, and path type before exposing the action.
Confidence Score: 4/5Safe to merge; the implementation is well-guarded on all code paths exercised by tests, with the one honest gap being Windows/WSL runtime validation, which is acknowledged and scoped to unit-only coverage. The delete path is thoroughly tested (unit + e2e on Linux CI) and follows defense-in-depth: renderer UI gate, main-process path re-validation, disk-level kind + realpath checks, and cache-generation guards to prevent deleted sessions from resurfacing. The code path exercised least at runtime is Windows/WSL — specifically the WSL UNC directory-removal branch and the interaction of WSL home expansions with the realpath root checks. The author acknowledges this gap and recommends reviewers give that path a closer look before merging on Windows. Files Needing Attention: The WSL UNC directory removal path in
|
| Filename | Overview |
|---|---|
| src/main/ai-vault/session-delete-target.ts | Pure validator that re-derives per-agent session roots from the same constants the scanner uses, checks path membership and file predicates, and builds an ordered removal plan. Degenerate-stem guard for claude prevents trashing the whole project directory. |
| src/main/ai-vault/session-delete.ts | Executor that runs the removal plan: tries WSL UNC fast path first, then lstat (kind check, symlink-safe), then realpath (roots re-realpath'd to handle symlinked-parent escape), then shell.trashItem. ENOENT treated as success throughout for idempotency. |
| src/main/ipc/ai-vault-delete.ts | IPC adapter: fetches WSL homes, delegates to executor, and on success invalidates all three caches. Parse-cache invalidation deliberately uses the raw renderer-echoed filePath (not resolve()-d) to match the scanner's own key. |
| src/shared/ai-vault-session-deletion.ts | Shared type and constant module. AI_VAULT_DELETABLE_AGENTS is the single source of truth used by both renderer and main, preventing disagreement on what is deletable. |
| src/main/ai-vault/cached-session-list.ts | Adds cacheGeneration counter to prevent a stale in-flight scan from overwriting the cache after a delete invalidation. Logic is correct. |
| src/main/ipc/ai-vault.ts | Registers the delete handler; adds the same cacheGeneration guard for the multi-host list cache. The aiVaultDeleteDeps const references a hoisted function declaration — valid JS, correctly noted in a comment. |
| src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts | Renderer-side UI gate. Correctly checks session-live state last. The renderer gate is a subset of the main-side gate, as required. |
| src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts | Custom hook owning the confirm-delete flow: in-flight guard, IPC call, toast on settle, force-refresh. Error handling covers both rejected/failed outcomes and IPC transport errors. |
| src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx | Computes deletability once and passes it to both dropdown and context menus. Adds the portalled-menu click bubble-cancellation fix. |
| src/renderer/src/components/right-sidebar/AiVaultSessionDeleteDialog.tsx | Confirmation dialog with lastSessionRef pattern to prevent blank flash during Radix close animation. Disabled buttons during in-flight delete. |
| tests/e2e/ai-vault-session-delete.spec.ts | E2E spec against isolated HOME: covers single-file delete (gemini) and directory-shaped delete with companions (claude) including the file-history preservation assertion. |
| src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx | Adds Delete item with accessible aria-label, wrapping div for tooltip hover over disabled item, and tooltip content explaining the limit without leaking storage internals. |
| src/renderer/src/web/web-preload-api.ts | Web stub for deleteSession correctly returns a non-local-host rejection rather than throwing or pretending to delete. |
Reviews (2): Last reviewed commit: "fix(ai-vault): address review findings o..." | Re-trigger Greptile
|
reviewing now |
1cbaa8b to
31c884c
Compare
…ders Add the pure judgement layer for deleting an Agent Session History entry. `validateAiVaultSessionDeleteTarget` decides whether a session may be removed: the agent must be one of the nine providers where a single file is the whole session (gemini, copilot, cursor, hermes, devin, openclaw, droid, pi, omp), the host must be local, and the renderer-supplied path must resolve inside that agent's own session roots and match its discovery predicate. To keep the delete roots from drifting from the scanner's own roots, the WSL-expansion helper moves to session-scanner-root-dirs.ts and the OpenClaw root derivation + session predicate become shared helpers that discoverOpenClawFiles itself consumes. The result is path-only and never touches the filesystem; a returned `allowed: true` still requires an lstat/realpath re-check in the executor (S-2) before removal, documented as a caller contract on the result type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
Add the filesystem executor behind session deletion. It calls the S-1 path validator, then performs the fs-side guards that validator documented it could not: lstat().isFile() rejects a directory or symlink, and realpath is re-fed through the validator so a regular file reached through a symlinked parent that escapes the agent's roots is rejected too. Only then is the file moved to the OS trash via shell.trashItem, with ENOENT treated as success so a delete racing an external removal stays idempotent. WSL UNC paths (no Recycle Bin) are delegated to tryDeleteWslUncPath before the Windows-local fs guards, mirroring fs:deletePath. Any non-ENOENT error is returned as a failure result rather than thrown, since IPC payloads are untyped at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
…alidation
Wire the S-2 delete executor to an IPC endpoint and expose it on the preload
bridge. The renderer calls aiVault:deleteSession with { agent, filePath,
executionHostId }; the handler fetches WSL homes, delegates to the executor
(which re-validates and trashes), and on a real delete invalidates the caches
that could otherwise keep serving the deleted session.
Cache invalidation is generation-guarded: a scan already in flight when the
delete lands carries an older generation and must not write its pre-delete
result back into the cache. Without this, an in-flight scan resolving just
after the delete would resurrect the deleted session for the 15s TTL — and
force-refreshing the panel only masks it for the desktop, not for the paired
mobile client or runtime RPC that share the same cache module. Both the shared
local-scope cache and the desktop multi-host cache carry the guard, with
regression tests for the in-flight race.
The delete result type moves to shared/ai-vault-types.ts so the renderer can
import the same contract the executor returns. To keep ai-vault.ts within the
max-lines budget after adding the delete wiring, two cohesive pieces are
extracted to their own files: the delete orchestration (ai-vault-delete.ts)
and listAiVaultSubagentSessions (ai-vault-subagent-list.ts). The latter is the
only handler with no dependency on this module's private cache state, so it is
the one piece that moves verbatim without threading state through a seam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
Add the renderer counterpart to the main-side delete validator: given a session, decide whether the row menu shows Delete enabled, or disabled with a reason a tooltip can render. It reuses the shared deletable-agent set and unsupported-reason map so the two sides can never disagree about which agents are deletable, and reuses the existing local-host / synthetic-path renderer helpers. This is intentionally not a security boundary — it validates neither the path root nor the file predicate. Those are the main process's untrusted-input defense; the renderer only picks the affordance, and the main side re-checks on delete regardless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
…agent The renderer deletability check runs host -> synthetic -> agent, while the main validator runs agent -> host -> synthetic. The two layers agree only on deletable-or-not (renderer-false is a subset of main-false), not on the reason code a doubly-failing session carries. Document that explicitly instead of implying the orders match, and add the antigravity case (two reason codes) so the agentReasonCodes array shape is actually exercised. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
…n dialog Wire the delete affordance into AI Vault. Both the dropdown and the context menu gain a destructive Delete item; a session that can't be completely deleted (remote host, synthetic OpenCode-SQLite path, or a directory/registry-backed agent) shows the item disabled with a reason surfaced both as a tooltip and as an aria-label so keyboard and screen-reader users learn why. Confirming opens a dialog that names the session and states it will no longer be resumable from the provider's own CLI, then calls the delete IPC and force-refreshes the list for immediate feedback (the main side has already invalidated its caches). The confirmation copy says the session "will be deleted" rather than "moved to the trash": on Windows a WSL session is deleted with rm inside the distro (no Recycle Bin), so promising recoverability would be a lie on that platform. Deletability is computed once per row and shared by both menus so they can never disagree. New pure logic — the reason-to-tooltip mapping (including the multi-reason join) and the delete action hook's deleted/rejected/failed branches — is covered by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
The disabled Delete item explained a provider's storage layout to the user
("Claude sessions can't be deleted here: stores sessions as a folder, not a
single file"). That is Orca's problem, not the reader's — the tooltip now says
which sessions are affected and stops there. The non-local-host string stays as
it was: it states scope, not a cause, and tells the user what would work.
The reason-code plumbing existed only to compose that tooltip, so
AI_VAULT_UNSUPPORTED_DELETE_REASONS, AiVaultUnsupportedDeleteReasonCode, and the
renderer result's agentReasonCodes field go with it. Why each agent is excluded
moves into the comment above AI_VAULT_DELETABLE_AGENTS, where a reader looking
up the deletable set will find it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
…tory These three were excluded only because the delete unit was one file. Their sessions are directories — claude keeps Task subagent transcripts in a sibling `<uuid>/subagents/`, rovo and grok keep everything under `<sessionId>/` — and nothing in them is shared with another session, so a directory-aware delete is still a complete delete. Supported goes from 9 agents to 12; the four that remain (antigravity, kimi, codex, opencode) are blocked by a registry or a SQLite row, which no delete unit fixes. Validation now returns an ordered removal plan instead of a single path. Each removal carries the kind it must be on disk and the roots its realpath must stay inside, so the executor's guard is the same shape for a file and for a directory. Companions come first and the transcript last: the transcript is what puts the row on screen, so a part-way failure leaves the row to retry from rather than dropping it and stranding the rest on disk. Claude's `session-env/<uuid>/` goes with the transcript — it holds that session's generated shell exports and nothing else. Its sibling `file-history/<uuid>/` deliberately does not: it is the rewind buffer holding earlier versions of the user's own files, and retiring a session is no reason to take away the only copy that can restore them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
…subagents Deleting a claude session trashed `<uuid>/subagents/` and left `<uuid>/` behind as an empty directory — one per deleted session, accumulating under every project. The directory is named after the transcript, so it belongs to that session as a whole; take it rather than the one subdirectory inside it. Still derived from the scanner's own subagents path, so the two cannot drift. Reaching the parent means a degenerate stem now matters: `..jsonl` passes the extension check and its stem is `.`, which would resolve the session directory to the project directory holding every session. Reject it instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
Radix portals the row's dropdown and context menus out of its DOM, but React still bubbles their clicks back through the component tree, so every menu selection also hit the row's own click handler and expanded it. The trigger button already stopped propagation, which is why opening the menu looked fine and only choosing an item misbehaved. It shows worst on Delete: the row expands behind the confirm dialog, so cancelling leaves the list rearranged under a dialog the user just backed out of. Toggle details only for clicks that land in the row's own subtree — that covers the context menu and any future portalled surface, not just this one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
…ion and mid-delete dismissal Two robustness gaps flagged in review: - handleConfirmDelete only branched on result.outcome. The main handler resolves with a 'failed'/'rejected' outcome rather than throwing, but the IPC invoke itself can still reject on a transport/serialization error, and the caller fires it with `void`. That reject would surface as an unhandled rejection with no toast. Catch it and show the same generic failure toast. - handleDialogOpenChange cleared sessionPendingDelete on every open=false. The Cancel button is disabled mid-delete, but Radix still fires its Escape/outside-click/X close, which could dismiss an in-flight delete out from under itself. Ignore close requests while deletingSession is true. Both covered by regression tests (verified failing without the fix).
…anch Directory-shaped deletes (claude's subagents/session-env dirs, rovo/grok's session dir) gated the WSL branch on kind === 'file', so on Windows a session under a WSL distro home fell through to shell.trashItem — which can't trash a WSL-volume item (no Recycle Bin) and throws, or worse is silently stranded when the 9P filesystem's unreliable lstat false-reports ENOENT and the executor treats that as success. Single-file deletes predate the directory kinds, so the file-only gate was correct until directory removals were added. tryDeleteWslUncPath already supports recursive removal; pass recursive for directory removals so they take the same WSL rm path as files instead of shell.trashItem. Covered by two regression tests (file: non-recursive, directory: recursive), verified failing without the fix. Also drops the internal ledger-ID references (D-*, S-*) from comments in these two files; they pointed at a private design doc a reader can't see.
Comments across the session-delete feature cited decision/slice IDs (D-1..D-7, S-1..S-5) from a private design document. Those references are meaningless to anyone reading the code without that doc, so remove the IDs while keeping the reasoning each comment carried. No behavior change.
The unit tests mock lstat/realpath/trashItem, so nothing proved the whole IPC path actually removes files. This spec seeds sessions into the E2E harness's isolated HOME and deletes them through window.api.aiVault.deleteSession: - a single-file session (gemini): the transcript is gone from disk and drops out of the list. - a directory-shaped session (claude): the transcript, the <uuid>/ session directory (subagents included, no empty shell left), and the session-env companion are all gone, while the file-history rewind buffer is preserved. Verified failing when the executor's removal is stubbed out. Runs on Linux CI.
Three points raised in review: - Disable Delete for a still-running session. resolveAiVaultSessionDeletability now gates on liveState (working/blocked/waiting) last — an otherwise-deletable session that is mid-run shows "wait for it to finish" instead of an enabled Delete, so trashing a live agent's transcript can't drop writes it is still appending. Unsupported/remote sessions keep their permanent reason. - Realpath the roots, not just the target, in the executor's escape check. The roots were only resolve()'d (text), so a session under a symlinked root (~/.claude -> /Volumes/…) was falsely rejected; realpath each root (falling back to its text form when it can't be resolved) before the membership check. - Invalidate the parse cache with the raw filePath, not resolve(filePath). The cache is keyed by the exact path the scanner discovered, so resolve() could normalise it away from the stored key and miss. Drops the now-unused import. Also moves AiVaultDeleteSessionArgs/Result out of ai-vault-types.ts (which the upstream merge pushed over the max-lines limit) into the ai-vault-session-deletion domain module they belong to, and updates importers. Regression tests added for the live gate, the symlinked-root accept, and the reason string; verified failing without each fix.
The bridge declared Promise<unknown> while AiVaultApi.deleteSession promises AiVaultDeleteSessionResult, so the preload object leaned on the api-types declaration to stay honest instead of being checked against it. Co-authored-by: Orca <help@stably.ai>
Comments across the delete flow explained HOW alongside WHY and ran to a dozen lines; they now carry only the non-obvious reasoning. The excluded-agent rationale, the caller contract on the validator, and the file-history carve-out are kept — those are knowledge, not narration. Also removes three duplications the feature introduced: - AiVaultSessionDeleteExecutionResult was an alias for AiVaultDeleteSessionResult whose comment pointed at a module the type no longer lives in. - The synthetic-path predicate existed twice under near-identical names; the renderer now re-exports the shared one it already had a sibling import of. - The delete-failure toast was written out verbatim in both the rejected and the thrown branch. Co-authored-by: Orca <help@stably.ai>
… selector
The confirm dialog pinned an arbitrary sm:max-w-[440px]; every other dialog in
the right sidebar uses a scale token, and md (448px) covers the role.
The row-expand test selected the row by [draggable="true"], which stopped
naming the row when draggable moved to the title element upstream. It still
passed by bubbling, so the comment was the only thing wrong — now it selects
the title deliberately and says why the query is first-match (Radix's asChild
trigger repeats the subtree, so screen.get* sees duplicates).
Also types the e2e delete helper as AiVaultDeleteSessionResult instead of a
hand-written { outcome: string }, now that the preload bridge returns it.
Co-authored-by: Orca <help@stably.ai>
Discovery and deletion now share the same agent source definitions, eliminating the risk of them drifting apart. A single `AI_VAULT_AGENT_SOURCES` table declares each agent's root directories, file extensions, and acceptance predicates. Replaced the custom delete confirmation dialog with the system dialog, simplifying the delete action hook and removing boilerplate state management.
8c2c5a5 to
c1bbf95
Compare
Resolves the conflict with stablyai#10249's table refactor: per-agent discovery moved from session-scanner-source-discovery.ts into the AI_VAULT_AGENT_SOURCES table, so the Prime Agent source becomes a table entry following the Pi shape (env resolution via primeAgentSessionsDirFromEnv unchanged). Prime Agent stays out of AI_VAULT_DELETABLE_AGENTS. Also picks up stablyai#12999 (unknown-agent tolerance in session-list validation) with no overlap.
…ist (stablyai#10249)" This reverts commit a77002c.
…blyai#10249) * feat(ai-vault): validate session-delete targets for single-file providers Add the pure judgement layer for deleting an Agent Session History entry. `validateAiVaultSessionDeleteTarget` decides whether a session may be removed: the agent must be one of the nine providers where a single file is the whole session (gemini, copilot, cursor, hermes, devin, openclaw, droid, pi, omp), the host must be local, and the renderer-supplied path must resolve inside that agent's own session roots and match its discovery predicate. To keep the delete roots from drifting from the scanner's own roots, the WSL-expansion helper moves to session-scanner-root-dirs.ts and the OpenClaw root derivation + session predicate become shared helpers that discoverOpenClawFiles itself consumes. The result is path-only and never touches the filesystem; a returned `allowed: true` still requires an lstat/realpath re-check in the executor (S-2) before removal, documented as a caller contract on the result type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): move a validated session transcript to the trash Add the filesystem executor behind session deletion. It calls the S-1 path validator, then performs the fs-side guards that validator documented it could not: lstat().isFile() rejects a directory or symlink, and realpath is re-fed through the validator so a regular file reached through a symlinked parent that escapes the agent's roots is rejected too. Only then is the file moved to the OS trash via shell.trashItem, with ENOENT treated as success so a delete racing an external removal stays idempotent. WSL UNC paths (no Recycle Bin) are delegated to tryDeleteWslUncPath before the Windows-local fs guards, mirroring fs:deletePath. Any non-ENOENT error is returned as a failure result rather than thrown, since IPC payloads are untyped at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): delete-session IPC handler, preload bridge, cache invalidation Wire the S-2 delete executor to an IPC endpoint and expose it on the preload bridge. The renderer calls aiVault:deleteSession with { agent, filePath, executionHostId }; the handler fetches WSL homes, delegates to the executor (which re-validates and trashes), and on a real delete invalidates the caches that could otherwise keep serving the deleted session. Cache invalidation is generation-guarded: a scan already in flight when the delete lands carries an older generation and must not write its pre-delete result back into the cache. Without this, an in-flight scan resolving just after the delete would resurrect the deleted session for the 15s TTL — and force-refreshing the panel only masks it for the desktop, not for the paired mobile client or runtime RPC that share the same cache module. Both the shared local-scope cache and the desktop multi-host cache carry the guard, with regression tests for the in-flight race. The delete result type moves to shared/ai-vault-types.ts so the renderer can import the same contract the executor returns. To keep ai-vault.ts within the max-lines budget after adding the delete wiring, two cohesive pieces are extracted to their own files: the delete orchestration (ai-vault-delete.ts) and listAiVaultSubagentSessions (ai-vault-subagent-list.ts). The latter is the only handler with no dependency on this module's private cache state, so it is the one piece that moves verbatim without threading state through a seam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): renderer judgement for whether Delete is offered Add the renderer counterpart to the main-side delete validator: given a session, decide whether the row menu shows Delete enabled, or disabled with a reason a tooltip can render. It reuses the shared deletable-agent set and unsupported-reason map so the two sides can never disagree about which agents are deletable, and reuses the existing local-host / synthetic-path renderer helpers. This is intentionally not a security boundary — it validates neither the path root nor the file predicate. Those are the main process's untrusted-input defense; the renderer only picks the affordance, and the main side re-checks on delete regardless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * docs(ai-vault): correct deletability parity claim; test multi-reason agent The renderer deletability check runs host -> synthetic -> agent, while the main validator runs agent -> host -> synthetic. The two layers agree only on deletable-or-not (renderer-false is a subset of main-false), not on the reason code a doubly-failing session carries. Document that explicitly instead of implying the orders match, and add the antigravity case (two reason codes) so the agentReasonCodes array shape is actually exercised. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): add Delete to the session row menu with a confirmation dialog Wire the delete affordance into AI Vault. Both the dropdown and the context menu gain a destructive Delete item; a session that can't be completely deleted (remote host, synthetic OpenCode-SQLite path, or a directory/registry-backed agent) shows the item disabled with a reason surfaced both as a tooltip and as an aria-label so keyboard and screen-reader users learn why. Confirming opens a dialog that names the session and states it will no longer be resumable from the provider's own CLI, then calls the delete IPC and force-refreshes the list for immediate feedback (the main side has already invalidated its caches). The confirmation copy says the session "will be deleted" rather than "moved to the trash": on Windows a WSL session is deleted with rm inside the distro (no Recycle Bin), so promising recoverability would be a lie on that platform. Deletability is computed once per row and shared by both menus so they can never disagree. New pure logic — the reason-to-tooltip mapping (including the multi-reason join) and the delete action hook's deleted/rejected/failed branches — is covered by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * fix(ai-vault): state that Delete is unavailable without naming the cause The disabled Delete item explained a provider's storage layout to the user ("Claude sessions can't be deleted here: stores sessions as a folder, not a single file"). That is Orca's problem, not the reader's — the tooltip now says which sessions are affected and stops there. The non-local-host string stays as it was: it states scope, not a cause, and tells the user what would work. The reason-code plumbing existed only to compose that tooltip, so AI_VAULT_UNSUPPORTED_DELETE_REASONS, AiVaultUnsupportedDeleteReasonCode, and the renderer result's agentReasonCodes field go with it. Why each agent is excluded moves into the comment above AI_VAULT_DELETABLE_AGENTS, where a reader looking up the deletable set will find it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * feat(ai-vault): delete claude, rovo, and grok sessions by their directory These three were excluded only because the delete unit was one file. Their sessions are directories — claude keeps Task subagent transcripts in a sibling `<uuid>/subagents/`, rovo and grok keep everything under `<sessionId>/` — and nothing in them is shared with another session, so a directory-aware delete is still a complete delete. Supported goes from 9 agents to 12; the four that remain (antigravity, kimi, codex, opencode) are blocked by a registry or a SQLite row, which no delete unit fixes. Validation now returns an ordered removal plan instead of a single path. Each removal carries the kind it must be on disk and the roots its realpath must stay inside, so the executor's guard is the same shape for a file and for a directory. Companions come first and the transcript last: the transcript is what puts the row on screen, so a part-way failure leaves the row to retry from rather than dropping it and stranding the rest on disk. Claude's `session-env/<uuid>/` goes with the transcript — it holds that session's generated shell exports and nothing else. Its sibling `file-history/<uuid>/` deliberately does not: it is the rewind buffer holding earlier versions of the user's own files, and retiring a session is no reason to take away the only copy that can restore them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): remove a claude session's own directory, not just its subagents Deleting a claude session trashed `<uuid>/subagents/` and left `<uuid>/` behind as an empty directory — one per deleted session, accumulating under every project. The directory is named after the transcript, so it belongs to that session as a whole; take it rather than the one subdirectory inside it. Still derived from the scanner's own subagents path, so the two cannot drift. Reaching the parent means a degenerate stem now matters: `..jsonl` passes the extension check and its stem is `.`, which would resolve the session directory to the project directory holding every session. Reject it instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): keep a session row collapsed when a menu action is chosen Radix portals the row's dropdown and context menus out of its DOM, but React still bubbles their clicks back through the component tree, so every menu selection also hit the row's own click handler and expanded it. The trigger button already stopped propagation, which is why opening the menu looked fine and only choosing an item misbehaved. It shows worst on Delete: the row expands behind the confirm dialog, so cancelling leaves the list rearranged under a dialog the user just backed out of. Toggle details only for clicks that land in the row's own subtree — that covers the context menu and any future portalled surface, not just this one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): harden the delete-confirmation flow against IPC rejection and mid-delete dismissal Two robustness gaps flagged in review: - handleConfirmDelete only branched on result.outcome. The main handler resolves with a 'failed'/'rejected' outcome rather than throwing, but the IPC invoke itself can still reject on a transport/serialization error, and the caller fires it with `void`. That reject would surface as an unhandled rejection with no toast. Catch it and show the same generic failure toast. - handleDialogOpenChange cleared sessionPendingDelete on every open=false. The Cancel button is disabled mid-delete, but Radix still fires its Escape/outside-click/X close, which could dismiss an in-flight delete out from under itself. Ignore close requests while deletingSession is true. Both covered by regression tests (verified failing without the fix). * fix(ai-vault): route WSL UNC directory removals through the WSL rm branch Directory-shaped deletes (claude's subagents/session-env dirs, rovo/grok's session dir) gated the WSL branch on kind === 'file', so on Windows a session under a WSL distro home fell through to shell.trashItem — which can't trash a WSL-volume item (no Recycle Bin) and throws, or worse is silently stranded when the 9P filesystem's unreliable lstat false-reports ENOENT and the executor treats that as success. Single-file deletes predate the directory kinds, so the file-only gate was correct until directory removals were added. tryDeleteWslUncPath already supports recursive removal; pass recursive for directory removals so they take the same WSL rm path as files instead of shell.trashItem. Covered by two regression tests (file: non-recursive, directory: recursive), verified failing without the fix. Also drops the internal ledger-ID references (D-*, S-*) from comments in these two files; they pointed at a private design doc a reader can't see. * docs(ai-vault): drop internal design-ledger IDs from shipped comments Comments across the session-delete feature cited decision/slice IDs (D-1..D-7, S-1..S-5) from a private design document. Those references are meaningless to anyone reading the code without that doc, so remove the IDs while keeping the reasoning each comment carried. No behavior change. * test(ai-vault): e2e-cover the real on-disk session delete The unit tests mock lstat/realpath/trashItem, so nothing proved the whole IPC path actually removes files. This spec seeds sessions into the E2E harness's isolated HOME and deletes them through window.api.aiVault.deleteSession: - a single-file session (gemini): the transcript is gone from disk and drops out of the list. - a directory-shaped session (claude): the transcript, the <uuid>/ session directory (subagents included, no empty shell left), and the session-env companion are all gone, while the file-history rewind buffer is preserved. Verified failing when the executor's removal is stubbed out. Runs on Linux CI. * fix(ai-vault): address review findings on the session-delete flow Three points raised in review: - Disable Delete for a still-running session. resolveAiVaultSessionDeletability now gates on liveState (working/blocked/waiting) last — an otherwise-deletable session that is mid-run shows "wait for it to finish" instead of an enabled Delete, so trashing a live agent's transcript can't drop writes it is still appending. Unsupported/remote sessions keep their permanent reason. - Realpath the roots, not just the target, in the executor's escape check. The roots were only resolve()'d (text), so a session under a symlinked root (~/.claude -> /Volumes/…) was falsely rejected; realpath each root (falling back to its text form when it can't be resolved) before the membership check. - Invalidate the parse cache with the raw filePath, not resolve(filePath). The cache is keyed by the exact path the scanner discovered, so resolve() could normalise it away from the stored key and miss. Drops the now-unused import. Also moves AiVaultDeleteSessionArgs/Result out of ai-vault-types.ts (which the upstream merge pushed over the max-lines limit) into the ai-vault-session-deletion domain module they belong to, and updates importers. Regression tests added for the live gate, the symlinked-root accept, and the reason string; verified failing without each fix. * fix(ai-vault): type the deleteSession preload bridge as its real result The bridge declared Promise<unknown> while AiVaultApi.deleteSession promises AiVaultDeleteSessionResult, so the preload object leaned on the api-types declaration to stay honest instead of being checked against it. Co-authored-by: Orca <help@stably.ai> * refactor(ai-vault): tighten the session-delete code to house style Comments across the delete flow explained HOW alongside WHY and ran to a dozen lines; they now carry only the non-obvious reasoning. The excluded-agent rationale, the caller contract on the validator, and the file-history carve-out are kept — those are knowledge, not narration. Also removes three duplications the feature introduced: - AiVaultSessionDeleteExecutionResult was an alias for AiVaultDeleteSessionResult whose comment pointed at a module the type no longer lives in. - The synthetic-path predicate existed twice under near-identical names; the renderer now re-exports the shared one it already had a sibling import of. - The delete-failure toast was written out verbatim in both the rejected and the thrown branch. Co-authored-by: Orca <help@stably.ai> * refactor(ai-vault): use a design-system dialog width and a stable row selector The confirm dialog pinned an arbitrary sm:max-w-[440px]; every other dialog in the right sidebar uses a scale token, and md (448px) covers the role. The row-expand test selected the row by [draggable="true"], which stopped naming the row when draggable moved to the title element upstream. It still passed by bubbling, so the comment was the only thing wrong — now it selects the title deliberately and says why the query is first-match (Radix's asChild trigger repeats the subtree, so screen.get* sees duplicates). Also types the e2e delete helper as AiVaultDeleteSessionResult instead of a hand-written { outcome: string }, now that the preload bridge returns it. Co-authored-by: Orca <help@stably.ai> * refactor(ai-vault): consolidate agent sources and use system dialog Discovery and deletion now share the same agent source definitions, eliminating the risk of them drifting apart. A single `AI_VAULT_AGENT_SOURCES` table declares each agent's root directories, file extensions, and acceptance predicates. Replaced the custom delete confirmation dialog with the system dialog, simplifying the delete action hook and removing boilerplate state management. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
Summary
Closes #9876.
Adds a Delete action to AI Vault session rows that removes a provider's session for real — moving it to the trash — behind a confirmation dialog.
AI Vault accumulates every local agent session across 16 providers, with no way to retire a finished one; they pile up and slow every scan. Deleting is the natural way to retire a session, but only if it's complete: an Orca that says "deleted" while the conversation is still on disk (or reappears on the next scan) is worse than no delete at all. So Delete is offered only where Orca can remove the session completely, and shown disabled (not hidden) elsewhere so the row's absence is never mistaken for a bug.
Supported (12 of 16 providers) — everything the session wrote lives at paths derived from the one path the scanner surfaced, and nothing there is shared with another session:
<uuid>/, rovo/grok keep everything under<sessionId>/, so a directory-aware delete is still complete.Not supported (4): antigravity, kimi (dangling registry entry), codex (registry + hardlink aliases), opencode (a SQLite row, not a file). These show a disabled Delete item whose tooltip states the limit without exposing the provider's storage internals.
Design points:
shell.trashItem) — deleting another app's data must be reversible;ENOENTis treated as success so an external delete stays idempotent.session-env/<uuid>/(generated shell exports) is removed with the session;file-history/<uuid>/(the rewind buffer holding earlier versions of the user's own files) is deliberately kept.Screenshots
No visual change beyond the new Delete menu item, its confirmation dialog, and the disabled state for unsupported providers. (Reviewer: happy to add a short recording — the interaction is menu → Delete → confirm.)
Testing
pnpm lintpnpm typecheckpnpm test(ai-vault suites green; a few unrelated suites fail only when git/electron env vars leak into the runner —GIT_ASKPASS,GIT_CONFIG_*,ELECTRON_RENDERER_URL— and pass in a clean env)pnpm buildTest coverage:
..escape, extension/predicate mismatch, non-local host, synthetic path, directory-shaped removal plans, degenerate stems.file-historypreserved, rovo/grok directory deletes, the disabled codex tooltip, consecutive deletes, and cancel.AI Review Report
Reviewed with an AI coding agent across the dimensions below.
path.join/resolve; no separator assumptions. WSL-home roots are expanded for validation. The one platform gap is the trash itself — WSL UNC paths have no recycle bin, handled by the existingwsl-unc-deletepath (rm -f), attempted before the lstat/realpath guards.non-local-host), matching the existing gating for Open/Reveal Log.mainpreserved upstream's memory-bounding of the parse-cache/list-cache (fix(memory): bound OOM-prone accumulators across Orca #10179) while keeping this branch's cache-generation guard.aria-label); destructive styling on the item and dialog.Fixed during review/testing: a leftover empty
<uuid>/directory after claude deletes (now removes the session directory itself, with a guard against a degenerate stem resolving to the project root), and rows expanding behind the confirm dialog due to React bubbling portalled menu clicks.Security Audit
resolve()collapses.., matches the provider's discovery predicate, and re-checks each removal on disk (lstatkind +realpathstill inside roots) before trashing — catching a regular file reached through a symlinked parent that escapes the roots.shell.trashItem(and the existing WSLrm -fhelper for UNC paths, which refuses a bare directory and never follows a symlink).Notes
rm -f(no recycle bin) rather than the trash — flagged in the confirm copy.deletePath.Platform verification (honest scope)
file-historypreserved, rovo/grok directory deletes, the disabled codex tooltip, consecutive deletes, cancel.tests/e2e/ai-vault-session-delete.spec.ts) runs on CI and proves the real on-disk removal (single-file + claude directory + companions, file-history kept).shell.trashItem— was found in review and fixed (route directory removals through the WSLrm -rfbranch). Reviewers may want to give the Windows/WSL path a closer look.ELI5
AI Vault session rows gain a Delete action that really moves the provider session to trash after confirm. Finished sessions no longer pile up forever and slow every scan.