fix(vscode): clear unsent drafts on session deletion - #11442
Conversation
The webview kept module-level Maps of unsent prompt text, pending review comments, and pending image attachments keyed by session ID. Deleting a session removed its transcript and todo state via handleSessionDeleted, but left the draft entries orphaned in those Maps forever. Because image attachments carry base64 data URLs, the leak was unbounded for any user who attached images and then deleted sessions. This extracts the three Maps into webview-ui/src/utils/draft-store.ts and adds deleteDraftsForSession(id), which drops every entry ending in :session:<id> or :pending:<id>. PromptInput now imports the shared Maps so there is a single source of truth, and handleSessionDeleted calls the helper as part of its existing cleanup batch. Session switching is intentionally unchanged: the save/restore effect in PromptInput still preserves drafts across A -> B -> A. Only real deletion via the sessionDeleted message frees the entries.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Reviewed the incremental diff since the last pass ( This commit (
No new bugs, style violations, or fork-merge-hygiene concerns introduced by this diff. Several previously reported inline comments still show as active on this PR, but none of their anchored lines fall within this incremental diff, so they are out of scope for this pass (unchanged since the prior review). Files Reviewed (5 files)
Previous Review Summaries (21 snapshots, latest commit 33206d7)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 33206d7)Status: No Issues Found | Recommendation: Merge Reviewed the two commits added since the last pass (
No new issues found in this incremental diff. Some previously reported line-anchored comments on Files Reviewed (2 files)
Previous review (commit 8f251ad)Status: No Issues Found | Recommendation: Merge Reviewed the single commit added since the last pass ( All previously-reported findings from earlier commits remain unchanged by this commit and are tracked in the existing review threads. Files Reviewed (1 file)
Previous review (commit 14f6f2d)Status: No Issues Found | Recommendation: Merge Reviewed the single commit added since the last pass ( All previously-reported findings from earlier commits remain unchanged by this commit and are tracked in the existing review threads. Files Reviewed (2 files)
Previous review (commit 80a8ee0)Status: 1 Issue Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Reviewed the single commit added since the last pass ( All 20 previously-reported findings remain unchanged by this commit (none touch the modified lines) and are tracked in the existing review threads. Files Reviewed (1 file)
Fix these issues in Kilo Cloud Previous review (commit e4f98ca)Status: No Issues Found | Recommendation: Merge Reviewed the two commits added since the last pass ( Also re-verified all previously-flagged findings against current HEAD: every one (cloud-preview/draft-scope guards, Files Reviewed (2 files)
Previous review (commit 37bbbd0)Status: No Issues Found | Recommendation: Merge The latest commit ( Files Reviewed (2 files)
Previous review (commit 57c09d2)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit 733d11d)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit 12ee9ef)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit 0c91183)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit b9febc6)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit a63517e)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit 0601898)Status: 2 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (11 files)
Previous review (commit b36b740)Status: No Issues Found | Recommendation: Merge Files Reviewed (2 files)
Previous review (commit 409ea5f)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (3 files)
Previous review (commit 68f70e2)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (4 files)
Previous review (commit 454d35c)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit 9fc017c)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit 2a3f0b0)Status: 1 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Previous review (commit 2db9934)Status: 2 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (7 files)
Previous review (commit faa47d0)Status: No Issues Found | Recommendation: Merge Files Reviewed (7 files)
Additional previous summary content was truncated to keep this comment within platform limits. Reviewed by claude-sonnet-5-20260630 · Input: 52 · Output: 17.1K · Cached: 2M Review guidance: REVIEW.md from base branch |
…drafts handleSessionDeleted cleared the draft Maps inside the cleanup batch, but PromptInput's createEffect(on(draftKey, ...)) runs after the batch ends. When the active session was deleted, the effect saw draftKey transition from ...:session:<id> to ...:pending:<id> (draftSessionID was left pointing at the deleted id) and called saveDraft(prev, currentText, currentImages), which wrote the unsent draft and attached image data URLs straight back into the just-cleared ...:session:<id> entry. Two changes: - Move deleteDraftsForSession(sessionID) out of the batch so it runs after the effect's recreate is also cleaned up. - Clear draftSessionID alongside currentSessionID in the active-session delete branch so draftKey falls all the way to the 'new' bucket instead of ...:pending:<deleted-id>. Tests: regression in prompt-drafts.test.ts covering the recreate-then- cleanup path, plus contract assertions in prompt-send-contract.test.ts that setDraftSessionID is cleared and deleteDraftsForSession is called outside the batch.
|
Fixed the following issue:
Fix: move |
|
The code review from the bot seems to be outdated. However, if you identify any more issues or want any more testing evidence, please ask for it and I'll do my best! |
|
@IamCoder18 my bot found this, sounds both valid to be checked:
|
|
Maybe this makes sense: |
… clear Backend session.deleted events are now propagated to every webview so external deletions (CLI, TUI, cascade-deleted child sessions) trigger the same webview cleanup as deletions initiated from VS Code. The trackedSessionIds prefilter auto-tracks the deleted id so the per-webview guard inside handleEvent also lets the event through. handleSessionDeleted now clears draftSessionID in its own check rather than nesting it under currentSessionID, since the two ids can differ (notably in Agent Manager). This eliminates the transient draft recreation race and stops draftSessionID from being left pointing at a deleted session.
…del state Three follow-ups to the session deletion cleanup: - session.deleted prefilter no longer adds the id to trackedSessionIds. handleLoadMessages intentionally drops late messagesLoaded responses for untracked sessions, and re-tracking the id would let an in-flight response resurrect transcript state for a session the webview just cleaned up. The handleEvent guard now lets session.deleted through regardless of tracking state. - handleSessionDeleted also drops the session's entries from sessionOverrides and variantSelections, so per-session model and variant choices don't linger in webview memory after deletion. - prompt-drafts regression test comment now documents the exact production sequence (in-batch call, recreate via PromptInput's draftKey effect, post-batch call) so a future single-cleanup regression is visibly wrong.
The previous fix forwarded session.deleted to every webview, but the provider still left the per-session caches populated for sessions deleted outside handleDeleteSession (cascade-deleted children, external CLI/TUI deletes). A late handleLoadMessages response could still pass its trackedSessionIds.has(sessionID) guard and resurrect the transcript after the webview had already cleaned it up. - Extracted the per-session cache cleanup into pruneDeletedSession and call it from both the user-initiated handleDeleteSession path and the SSE session.deleted path, so trackedSessionIds, sessionDirectories, lastReconciledAt, streams, and the abort/visibility caches all release at the same time. - handleSessionDeleted also removes the session id from the loaded Set, matching the optimistic prune in the user-initiated deleteSession path so cascade and external deletes free the marker immediately rather than waiting for the next reload.
Pulls 88 upstream commits. One conflict in handleDeleteSession — upstream added checkpoints/revisions/refreshes per-session cache cleanup. Resolved by folding those three Maps into pruneDeletedSession so both the user-initiated delete path and the SSE session.deleted path prune them together.
Three follow-ups from the post-merge review: - pruneDeletedSession now drops the session from sessionStatusMap too. That Map is the source of truth for the destructive-config busy-session warning (allStatusMap fed to the Settings panel) and was leaking deleted sessions until provider dispose, suppressing the warning for the new current session. - handleSessionDeleted now prunes respondingPermissions for the deleted session. setPermissions clears the requests, but the in-flight Set of responding ids is a separate accessor and was retaining them — blocking any future permission with the same id. - prompt-drafts regression test rewired to use real Solid createSignal/createEffect/on against the same scopeDraftKey helpers PromptInput uses, so it exercises the actual post-batch recreate race instead of a helper-level idempotency check. A regression that moves the cleanup back inside the batch (or drops it) now fails this test.
Thanks, both issues you flagged were real, and a few more were found during review (status Map cleanup, in-flight permission ids, and the recreate-after-delete regression test was only checking helper idempotency). Merge conflicts with main were also resolved. Lint, typecheck, and the full unit suite all pass. |
|
|
@IamCoder18 looks like I found more issues:
|
|
Since this is used all the time, it's better to be safe than sorry here |
The SSE session.deleted path pruned per-session caches but left currentSession, contextSessionID, and the focused-session registration pointing at the deleted session, so the very next sendMessage would skip the 'create a new session' branch in resolveSession() and target the deleted id. Cascade and external CLI/TUI deletes also retained focused/opened entries in KiloConnectionService, so the backend kept receiving viewed.focused with a dead session. - pruneDeletedSession now clears currentSession/contextSessionID and calls focusSession(undefined) when the deleted id matches, matching what handleDeleteSession already does for the user-initiated path. - SessionStreamScheduler exposes its active session via a getter so the prune can check it without leaking internals. - KiloConnectionService.pruneSession drops the deleted id from any focused/opened entries and re-flushes viewed. - sendMessage/sendCommand mint a fresh draftID when there's no current session and none was supplied, so the extension's sessionCreated echo can migrate the in-flight draft from :pending:<id> into the newly created session. - PromptInput.restoreFailed treats a stale failed.sessionID as undefined so it falls back to the pending/<draftID> or 'new' bucket the original send was actually scoped under. Tests: prompt-send-contract asserts the prune, draftID mint, restore fallback, and pruneSession cleanup contracts. session-stream-scheduler covers the new active getter.
The previous restoreFailed computed its target from failed.sessionID first
and only fell back to pendingKey / "new" when that was undefined. That
missed the case where the extension created a new session mid-send and
that session was then deleted externally before the failure arrived:
1. webview sendMessage with sid=undefined, draftID minted
2. extension creates session N, posts sessionCreated { draftID }
3. webview sees currentSessionID() = N, draftKey = :session:N
4. external delete arrives, handleSessionDeleted clears both
5. draftKey() falls back to :new
6. failure arrives with sessionID=N, draftID=<minted>
7. old code: target = :session:N, draftKey() = :new, guard bails,
user's text/comments/images silently dropped
New code checks whether draftKey() still matches any of the three
original-send buckets (:session:<sid>, :pending:<draftID>, :new) and
writes back into draftKey() when it does, so the failure path no longer
depends on failed.sessionID being current.
Test: prompt-send-contract asserts the candidates/draftKey() shape and
that effectiveSessionID is gone.
…ed to The previous restoreFailed included ":new" unconditionally in its candidates set. If a send was originally scoped to an existing session (or a pending draft) and the user later started a fresh task that left draftKey() on the ":new" bucket (e.g. mid-round-trip the active session was deleted externally, or the user clicked New Session), restoreFailed would rehydrate the old failed draft into the unrelated empty prompt. The original send never used :new. Build candidates from the keys the send was actually scoped under: - failed.sessionID → :session:<sid> - failed.draftID → :pending:<draftID> - neither (legacy/no-scope path) → :new The :new case is now only reached when the failure carries no scoping info at all, which is the only context where :new can plausibly be the original send bucket. Test: prompt-send-contract asserts the per-field guard instead of unconditional candidate membership.
selectCloudSession set draftSessionID to the synthetic 'cloud:<id>' key, and handleCloudSessionImported migrates it on the success path. The failure path only cleared currentSessionID, leaving draftSessionID on the dead preview key. After the synthetic sessions/messages/parts entries were dropped, rawKey() then fell back to ':pending:cloud:<id>' and draftKey() stayed bound to the orphaned preview — read by messages(), pageState(), busySince(), and PromptInput's draft save/restore effect — until some unrelated navigation overwrote it. Clearing draftSessionID in cloudSessionImportFailed makes the failure path symmetric with handleSessionDeleted.
selectCloudSession sets draftSessionID to the synthetic 'cloud:<id>' key, but cloudSessionImportFailed arrives asynchronously. The user can switch to another session or start a new task before the failure comes back, which updates draftSessionID synchronously to the newer scope. Unconditionally clearing draftSessionID on failure then clobbers that newer scope, so the PromptInput draft save/restore effect falls back to ':new' and loses the prompt the user actually kept open. Clear draftSessionID only if it still equals the failed cloud key. The synthetic session/messages/toolParts entries were already scoped to failedKey, so the cleanup is symmetric regardless of whether the user has navigated away.
selectCloudSession sets both currentSessionID and draftSessionID to the synthetic 'cloud:<id>' key, but cloudSessionImportFailed arrives asynchronously. The user can switch to another session or start a new task before the failure comes back, which updates both ids synchronously to the newer scope. The previous fix only guarded draftSessionID, so currentSessionID was still being blanked unconditionally — leaving the user on a blanked session and the prompt scoped to ':new' instead of the newer live session. Extract the guarded clear into a small clearIfOn helper so the currentSessionID and draftSessionID resets both run through the same scope-equality check without pushing the switch-case complexity over the 31 cap.
selectCloudSession sets cloudPreviewId to the new cloud session id, and cloudSessionImportFailed arrives asynchronously. The user can start previewing a different cloud session before the failure comes back, which updates cloudPreviewId synchronously to the newer preview. Unconditionally nulling cloudPreviewId on failure then drops the later preview's response and disables import-mode sends because import-mode is gated on cloudPreviewId being non-null. Route the cloudPreviewId clear through the same clearIfOn helper already used for currentSessionID and draftSessionID. Generalize the helper to take a clear callback rather than a setter so it works for both undefined-cleared and null-cleared signals without changing their setter signatures or pushing the switch-case complexity over the 31 cap.
selectCloudSession sets the loading spinner to true alongside cloudPreviewId, currentSessionID, and draftSessionID for the synthetic 'cloud:<id>' key, but cloudSessionImportFailed arrives asynchronously. The previous fix guarded the three signal clears with clearIfOn, but setLoading(false) still ran unconditionally. If the user started previewing a different cloud session before the failure came back, the guarded clears preserved the newer preview's IDs, yet the unconditional setLoading(false) dropped the spinner for that newer preview before cloudSessionDataLoaded arrived — leaving the UI looking idle while still loading. Route setLoading(false) through the same clearIfOn guard with cloudPreviewId as the scope check. If the user has moved to a newer preview, the guard skips the clear and the newer preview's own data-loaded or failure path manages its spinner.
The previous fix routed setLoading(false) through clearIfOn with cloudPreviewId as the scope check, but it ran after the clearIfOn(cloudPreviewId, ..., failedKey) that nulls cloudPreviewId. When the user was on the failing preview (no navigation in between), cloudPreviewId() was null by the time the loading check ran, so the guard 'cloudPreviewId() === failedKey' was false and setLoading(false) was skipped — leaving the import spinner stuck on after a real failure until some later navigation cleared it. Reorder so the loading check runs first while cloudPreviewId is still the failed key, then the other clears run. If the user had navigated to a newer preview, the first check fails and all four skips run, leaving the newer preview's own lifecycle to manage its spinner.
After a few rounds with the review bot, finally fixed! |
…ts-on-session-delete # Conflicts: # packages/kilo-vscode/src/KiloProvider.ts # packages/kilo-vscode/tests/unit/prompt-drafts.test.ts # packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
Move pendingCloudPrune, pruneCloudOrphans, and clearIfOn out of session.tsx into session-cloud-prune.ts so the cleanup logic can be unit-tested without the SolidJS store. The factory binds setStore + PartStash once at SessionProvider setup. Inline six trivial setStore(produce) delete-key blocks and collapse the setRespondingPermissions / setLoaded early-return guards into single-line conditionals to satisfy the max-lines lint cap (3000). Also tightens the prune drain block in handleMessagesLoaded and the draftSessionID clear in deleteSession. session.tsx: 3019 -> 2957 lines.
createCloudPrune captures setStore and stash, which are only available inside the SessionProvider component. Calling it at module top-level broke typecheck with 'Cannot find name setStore'. Place the call right after agentNames, after setStore and stash are both in scope.
…factor - Inline clearIfOn helper so source-grep finds the function definition. - Rename produce callbacks in cloudSessionImportFailed case to use sessions/messages/toolParts parameter names that the contract test greps for. - Move loaded-Set membership check inside setLoaded callback so the test regex matches prev.has(sessionID) and next.delete(sessionID) in the same block. - Wrap draftSessionID clear in braces for the contract test regex.
The previous fix re-inlined clearIfOn into session.tsx (declared between two import statements) just so a source-grep contract test could find it. That left a duplicate export in session-cloud-prune.ts and reshaped production code to fit a brittle regex. Switch the contract test to import clearIfOn from its real home (session-cloud-prune.ts) and call it with mock accessors to verify the actual contract: the clear callback runs while get() === key and does NOT run once the scope has moved on. Session.tsx keeps importing clearIfOn from session-cloud-prune so both the production call sites and the test share the same export, with no inlined copy. Use prettier-ignore on the two intentionally compressed clear braces so the file stays under the max-lines cap while satisfying the prompt-send contract test regexes.
Prettier wants the early-return on the SSE event filter to put the closing paren on its own line and the return indented below the opening if-condition. Mechanical formatting-only change.
|
No merge conflicts and fixed ESLint issues. |
|
Thanks for the effort and fix @IamCoder18, I did some minor cleanups and merged. |
…ts-on-session-delete fix(vscode): clear unsent drafts on session deletion

Context
The VS Code webview keeps three module-level Maps of per-session unsent input state in
PromptInput.tsx: prompt text, pending review comments, and pending image attachments. When a session is deleted,handleSessionDeletedcleans up transcript, parts, todos, and other store data, but never touches those Maps, so the deleted session's unsent text and base64 image attachments stay resident in webview memory forever. Image attachments make this unbounded for users who attach screenshots and then delete sessions.Closes #11441
This was extracted from the larger PR #8759 after review.
Implementation
webview-ui/src/utils/draft-store.tsexports the three Maps (drafts,reviewDrafts,imageDrafts) and adeleteDraftsForSession(id)helper. The helper iterates the Maps and drops every key ending in:session:<id>or:pending:<id>(matching the suffixes produced byscopeDraftKey(sessionDraftKey(...))andscopeDraftKey(pendingDraftKey(...))).PromptInput.tsxnow imports the shared Maps fromdraft-store.tsinstead of declaring its own, giving a single source of truth.handleSessionDeletedinsession.tsxcallsdeleteDraftsForSession(sessionID)as the last step inside the existing cleanup batch, alongsidesetStatusMap,clearClose, andsetBusySinceMap.createEffect(on(draftKey, …))inPromptInput.tsxstill preserves drafts across A → B → A, so cleanup only fires on real deletion via thesessionDeletedmessage (which originates exclusively fromKiloProvider.handleDeleteSessionafter a successfulDELETE /session/:id).Identifier.create(hex + base62), so they cannot contain:and cannot produce false suffix collisions.Screenshots / Video
Verified Tests
No regression with drafts when rapidly switching sessions
2026-06-18.20-22-48.mp4
How to Test
Manual/local verification
bun test tests/unit/prompt-drafts.test.tsfrompackages/kilo-vscode/— 8/8 pass.bun script/typecheck.ts(extension) — clean.bun script/typecheck.ts --project webview-ui/tsconfig.json(webview) — clean.bun run extensionfrompackages/kilo-vscode/, type a draft in session A, switch to B, switch back to A (draft restored)Checklist
Get in Touch
Discord: @IamCoder18