mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge pull request #11442 from IamCoder18/vscode/cleanup-drafts-on-session-delete
fix(vscode): clear unsent drafts on session deletion
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Free webview memory for deleted VS Code sessions by clearing unsent prompt text, review comments, and pending image attachments that were retained in the per-session draft cache after `sessionDeleted`.
|
||||
|
||||
Also restores an in-flight failed draft into the live prompt after a session is deleted mid-send (whether user-initiated or via external CLI/TUI/cascade delete), while never rehydrating it into a prompt the user explicitly cleared.
|
||||
@@ -1505,6 +1505,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// for the busy-session warning on Save.
|
||||
if (event.type === "session.status") return true
|
||||
|
||||
// session.deleted must always pass through so the webview can run its cleanup
|
||||
// (messages, parts, stash, todos, permissions, drafts, etc.) — including for
|
||||
// sessions that were never explicitly tracked here (e.g. child sessions
|
||||
// cascade-deleted with the parent, or external CLI deletions). We deliberately
|
||||
// do NOT re-track the deleted id: handleLoadMessages intentionally drops late
|
||||
// responses for sessions that have been pruned, and re-tracking would let an
|
||||
// in-flight messagesLoaded response resurrect transcript state for a session
|
||||
// the webview just cleaned up.
|
||||
if (event.type === "session.deleted") return true
|
||||
|
||||
return this.trackedSessionIds.has(sessionId)
|
||||
},
|
||||
(payload, directory) => {
|
||||
@@ -1949,6 +1959,43 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every per-session cache entry we hold for the given id. Shared between
|
||||
* the user-initiated delete path (handleDeleteSession, after the backend
|
||||
* confirms) and the SSE session.deleted path (cascaded child deletes and
|
||||
* external CLI/TUI deletes that arrive via the event stream), so both paths
|
||||
* leave trackedSessionIds, sessionDirectories, and the related Maps in the
|
||||
* same state — including currentSession / contextSessionID / focused-session
|
||||
* registration. Without clearing those three, resolveSession() would still
|
||||
* see the deleted id via this.currentSession and the next send would target
|
||||
* a session the backend has already deleted.
|
||||
*/
|
||||
private pruneDeletedSession(sessionID: string): void {
|
||||
this.trackedSessionIds.delete(sessionID)
|
||||
this.streams.drop(sessionID)
|
||||
this.visibleTaskStreams.delete(sessionID)
|
||||
this.syncedChildSessions.delete(sessionID)
|
||||
this.sessionDirectories.delete(sessionID)
|
||||
this.aborts.delete(sessionID)
|
||||
this.lastReconciledAt.delete(sessionID)
|
||||
this.checkpoints.delete(sessionID)
|
||||
this.revisions.delete(sessionID)
|
||||
this.refreshes.delete(sessionID)
|
||||
this.sessionStatusMap.delete(sessionID)
|
||||
this.costs.onSessionDeleted(sessionID)
|
||||
const deletedAlertLimit = this.activeAlerts.get(sessionID)
|
||||
if (deletedAlertLimit !== undefined) {
|
||||
this.activeAlerts.delete(sessionID)
|
||||
this.postMessage({ type: "sessionCostAlertResolved", sessionID: sessionID, limit: deletedAlertLimit })
|
||||
}
|
||||
this.connectionService.pruneSession(sessionID)
|
||||
if (this.currentSession?.id === sessionID) {
|
||||
this.contextSessionID = undefined
|
||||
this.setCurrentSession(null)
|
||||
}
|
||||
if (this.streams.focused === sessionID) this.focusSession(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deleting a session.
|
||||
*/
|
||||
@@ -1965,23 +2012,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
)
|
||||
await stopSessionProcesses(this.client, sessionID, workspaceDir)
|
||||
await this.client.session.delete({ sessionID, directory: workspaceDir }, { throwOnError: true })
|
||||
this.trackedSessionIds.delete(sessionID)
|
||||
this.streams.drop(sessionID)
|
||||
this.visibleTaskStreams.delete(sessionID)
|
||||
this.syncedChildSessions.delete(sessionID)
|
||||
this.costs.onSessionDeleted(sessionID)
|
||||
const deletedAlertLimit = this.activeAlerts.get(sessionID)
|
||||
if (deletedAlertLimit !== undefined) {
|
||||
this.activeAlerts.delete(sessionID)
|
||||
this.postMessage({ type: "sessionCostAlertResolved", sessionID: sessionID, limit: deletedAlertLimit })
|
||||
}
|
||||
this.sessionDirectories.delete(sessionID)
|
||||
this.aborts.delete(sessionID)
|
||||
this.lastReconciledAt.delete(sessionID)
|
||||
this.checkpoints.delete(sessionID)
|
||||
this.revisions.delete(sessionID)
|
||||
this.refreshes.delete(sessionID)
|
||||
this.connectionService.pruneSession(sessionID)
|
||||
this.pruneDeletedSession(sessionID)
|
||||
if (this.currentSession?.id === sessionID) {
|
||||
this.contextSessionID = undefined
|
||||
this.setCurrentSession(null)
|
||||
@@ -3727,9 +3758,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// message.part.* events are always session-scoped; drop if session unknown.
|
||||
if (!sessionID && isSessionScopedPartEvent(event.type)) return
|
||||
if (this.postModelUsageChanged(event, sessionID)) return
|
||||
if (event.type !== "indexing.status" && sessionID && !this.trackedSessionIds.has(sessionID)) {
|
||||
if (
|
||||
event.type !== "indexing.status" &&
|
||||
event.type !== "session.deleted" &&
|
||||
sessionID &&
|
||||
!this.trackedSessionIds.has(sessionID)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.updated" && typeof event.properties.info.cost === "number") {
|
||||
const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost)
|
||||
@@ -3818,6 +3853,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the per-session caches for deleted sessions so a late
|
||||
// handleLoadMessages response (or any other guarded read) can't resurrect
|
||||
// transcript state for a session the webview just cleaned up. The
|
||||
// prefilter lets session.deleted through without re-tracking, and the
|
||||
// handleEvent guard does the same — this is the matching prune.
|
||||
if (event.type === "session.deleted" && sessionID) {
|
||||
this.pruneDeletedSession(sessionID)
|
||||
}
|
||||
|
||||
if (!isLegacySyncEvent(event)) {
|
||||
const props = event.properties
|
||||
handleNetworkEvent(
|
||||
|
||||
@@ -157,6 +157,11 @@ export class SessionStreamScheduler {
|
||||
if (sessionID) this.flush(sessionID)
|
||||
}
|
||||
|
||||
/** Currently focused (active-lane) session ID, if any. */
|
||||
get focused(): string | undefined {
|
||||
return this.active
|
||||
}
|
||||
|
||||
setVisible(sessionID: string, visible: boolean): void {
|
||||
const changed = visible ? !this.visible.has(sessionID) : this.visible.has(sessionID)
|
||||
if (!changed) return
|
||||
|
||||
@@ -257,11 +257,25 @@ export class KiloConnectionService {
|
||||
* Remove all messageID → sessionID entries for a given session.
|
||||
* Called when a session is deleted or otherwise pruned so the map
|
||||
* does not grow unbounded over the extension lifetime.
|
||||
*
|
||||
* Also drops the session from any provider's focused or opened set
|
||||
* so the server's `viewed` notification stops advertising a deleted
|
||||
* id after external (CLI/TUI/cascade) deletes arrive via SSE.
|
||||
*/
|
||||
pruneSession(sessionId: string): void {
|
||||
for (const [mid, sid] of this.messageSessionIdsByMessageId) {
|
||||
if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid)
|
||||
}
|
||||
for (const [key, sid] of this.focused) {
|
||||
if (sid === sessionId) this.focused.delete(key)
|
||||
}
|
||||
for (const [key, ids] of this.opened) {
|
||||
if (!ids.includes(sessionId)) continue
|
||||
const next = ids.filter((id) => id !== sessionId)
|
||||
if (next.length === 0) this.opened.delete(key)
|
||||
else this.opened.set(key, next)
|
||||
}
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { beforeEach, describe, it, expect } from "bun:test"
|
||||
import { createEffect, createRoot, createSignal, on } from "solid-js"
|
||||
import { deleteDraftsForSession, drafts, imageDrafts, reviewDrafts } from "../../webview-ui/src/utils/draft-store"
|
||||
import {
|
||||
createdDraftKey,
|
||||
movePromptDraft,
|
||||
@@ -7,6 +9,102 @@ import {
|
||||
sessionDraftKey,
|
||||
} from "../../webview-ui/src/utils/prompt-drafts"
|
||||
|
||||
beforeEach(() => {
|
||||
drafts.clear()
|
||||
reviewDrafts.clear()
|
||||
imageDrafts.clear()
|
||||
})
|
||||
|
||||
describe("deleteDraftsForSession", () => {
|
||||
it("clears deleted-session drafts without touching other sessions", () => {
|
||||
drafts.set("prompt:default:session:a", "draft a")
|
||||
drafts.set("prompt:default:pending:a", "pending a")
|
||||
drafts.set("prompt:default:session:b", "draft b")
|
||||
reviewDrafts.set("prompt:default:session:a", [])
|
||||
imageDrafts.set("prompt:default:session:a", [])
|
||||
|
||||
deleteDraftsForSession("a")
|
||||
|
||||
expect(drafts.has("prompt:default:session:a")).toBe(false)
|
||||
expect(drafts.has("prompt:default:pending:a")).toBe(false)
|
||||
expect(drafts.get("prompt:default:session:b")).toBe("draft b")
|
||||
expect(reviewDrafts.has("prompt:default:session:a")).toBe(false)
|
||||
expect(imageDrafts.has("prompt:default:session:a")).toBe(false)
|
||||
})
|
||||
|
||||
it("is a no-op when given an empty id", () => {
|
||||
drafts.set("prompt:default:session:a", "draft a")
|
||||
deleteDraftsForSession("")
|
||||
expect(drafts.get("prompt:default:session:a")).toBe("draft a")
|
||||
})
|
||||
|
||||
it("clears drafts that PromptInput's draftKey effect recreates after the batch", () => {
|
||||
// Production race that motivated the post-batch deleteDraftsForSession call:
|
||||
// 1. handleSessionDeleted batches setCurrentSessionID(undefined) +
|
||||
// setDraftSessionID(undefined). PromptInput's draftKey memo transitions from
|
||||
// ":session:<id>" to the "new" bucket.
|
||||
// 2. PromptInput's createEffect(on(draftKey, ...)) runs after the batch and calls
|
||||
// saveDraft(prev, currentText, currentImages), writing the live prompt and any
|
||||
// attached image data URLs back into the just-cleared ":session:<id>" key.
|
||||
// 3. deleteDraftsForSession runs after the effect and clears the re-added entry.
|
||||
//
|
||||
// The test wires the same reactive plumbing — real Solid createSignal/createEffect/on
|
||||
// against the same scopeDraftKey/sessionDraftKey/pendingDraftKey helpers PromptInput
|
||||
// uses — so a regression that moves the cleanup back inside the batch (or drops it
|
||||
// entirely) leaks the recreated draft and the final assertion fails.
|
||||
const img = {
|
||||
id: "i1",
|
||||
filename: "x.png",
|
||||
mime: "image/png",
|
||||
dataUrl: "data:image/png;base64,AAAA",
|
||||
}
|
||||
const draftKey = "prompt:default:session:race"
|
||||
|
||||
createRoot((dispose) => {
|
||||
// Live prompt state, the way PromptInput tracks it.
|
||||
const [text, setText] = createSignal("draft a")
|
||||
const [images] = createSignal([img])
|
||||
const [currentSessionID, setCurrentSessionID] = createSignal<string | undefined>("race")
|
||||
const [draftSessionID, setDraftSessionID] = createSignal<string | undefined>("race")
|
||||
|
||||
const boxKey = "prompt:default"
|
||||
const rawKey = () =>
|
||||
sessionDraftKey(currentSessionID()) ?? pendingDraftKey(draftSessionID() ?? undefined) ?? "new"
|
||||
const key = () => scopeDraftKey(boxKey, rawKey())
|
||||
|
||||
// Pre-deletion: the user has unsent text and an attached image for this session.
|
||||
drafts.set(draftKey, text())
|
||||
imageDrafts.set(draftKey, images())
|
||||
|
||||
// Mirror the saveDraft behavior PromptInput's effect runs when draftKey transitions.
|
||||
createEffect(
|
||||
on(key, (k, prev) => {
|
||||
if (prev !== undefined && prev !== k) {
|
||||
drafts.set(prev, text())
|
||||
imageDrafts.set(prev, images())
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Production batch: clear the ids so draftKey transitions off ":session:<id>".
|
||||
setCurrentSessionID(undefined)
|
||||
setDraftSessionID(undefined)
|
||||
// Solid has now run the effect; the recreate happened. Sanity-check before cleanup.
|
||||
expect(drafts.has(draftKey)).toBe(true)
|
||||
expect(imageDrafts.has(draftKey)).toBe(true)
|
||||
|
||||
// The post-batch cleanup. A single in-batch call (run before the effect) would
|
||||
// have been wiped by the recreate above and not catch this — the post-batch
|
||||
// call is what actually frees the entry.
|
||||
deleteDraftsForSession("race")
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(drafts.has(draftKey)).toBe(false)
|
||||
expect(imageDrafts.has(draftKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sessionDraftKey", () => {
|
||||
it("prefixes session ids", () => {
|
||||
expect(sessionDraftKey("abc")).toBe("session:abc")
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { clearIfOn } from "../../webview-ui/src/context/session-cloud-prune"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx")
|
||||
const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx")
|
||||
const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts")
|
||||
const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
|
||||
const CONNECTION_SERVICE_FILE = path.join(ROOT, "src/services/cli-backend/connection-service.ts")
|
||||
|
||||
function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
@@ -116,3 +119,380 @@ describe("isPromptBlocked signature contract", () => {
|
||||
expect(params).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleSessionDeleted draft cleanup contract", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
|
||||
it("clears draftSessionID independently of currentSessionID when it equals the deleted id", () => {
|
||||
const body = extractFunctionBody(source, "handleSessionDeleted")
|
||||
const draftBlock = body.match(/if \(draftSessionID\(\) === sessionID\) \{([\s\S]*?)\}/)
|
||||
expect(draftBlock).not.toBeNull()
|
||||
expect(draftBlock![1]).toContain("setDraftSessionID(undefined)")
|
||||
// Must be a sibling check, not nested inside the currentSessionID branch —
|
||||
// otherwise a deleted but non-active session leaves draftSessionID stale.
|
||||
const activeBlock = body.match(/if \(currentSessionID\(\) === sessionID\) \{([\s\S]*?)\}/)
|
||||
expect(activeBlock![1]).not.toContain("setDraftSessionID")
|
||||
})
|
||||
|
||||
it("calls deleteDraftsForSession outside the cleanup batch so PromptInput's recreate is also cleaned up", () => {
|
||||
const body = extractFunctionBody(source, "handleSessionDeleted")
|
||||
const batchMatch = body.match(/batch\(\(\) => \{([\s\S]*?)\}\)/)
|
||||
expect(batchMatch).not.toBeNull()
|
||||
expect(batchMatch![1]).not.toContain("deleteDraftsForSession(sessionID)")
|
||||
const postBatch = body.slice((batchMatch!.index ?? 0) + batchMatch![0].length)
|
||||
expect(postBatch).toContain("deleteDraftsForSession(sessionID)")
|
||||
})
|
||||
|
||||
it("removes the deleted id from the loaded Set so cascade/external deletes free the marker", () => {
|
||||
// The user-initiated deleteSession() path prunes loaded optimistically, but
|
||||
// cascade deletes and external CLI/TUI deletes only come through
|
||||
// handleSessionDeleted. Without this, those ids stay in loaded until reload.
|
||||
const body = extractFunctionBody(source, "handleSessionDeleted")
|
||||
expect(body).toMatch(
|
||||
/setLoaded\(\s*\(prev\)\s*=>\s*\{[\s\S]*?prev\.has\(sessionID\)[\s\S]*?next\.delete\(sessionID\)[\s\S]*?\}\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("drops respondingPermissions entries that belong to the deleted session", () => {
|
||||
// setPermissions is cleared by removeSessionPermissions, but respondingPermissions
|
||||
// (the Set of in-flight permission ids) is a separate accessor that doesn't know
|
||||
// which ids belong to which session. Without an explicit prune here, a permission
|
||||
// request that the user was responding to when the session was deleted would keep
|
||||
// its id resident and block future requests with the same id.
|
||||
const body = extractFunctionBody(source, "handleSessionDeleted")
|
||||
expect(body).toContain("setRespondingPermissions")
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider pruneDeletedSession contract", () => {
|
||||
const source = readFile(KILOPROVIDER_FILE)
|
||||
|
||||
it("drops sessionStatusMap entries alongside the other per-session caches", () => {
|
||||
// sessionStatusMap is the source of truth for the destructive-config busy-session
|
||||
// warning (sessionStatusMap.size === 0 short-circuit, the allStatusMap fed to the
|
||||
// Settings panel). Without this prune, deleted sessions stay marked as
|
||||
// busy/retry/etc. until provider dispose, suppressing the "you have a busy session"
|
||||
// warning for the new current session.
|
||||
const match = source.match(/pruneDeletedSession\(sessionID: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toContain("this.sessionStatusMap.delete(sessionID)")
|
||||
})
|
||||
|
||||
it("clears currentSession and contextSessionID when the deleted id matches", () => {
|
||||
// The SSE session.deleted path runs pruneDeletedSession; if it leaves
|
||||
// currentSession pointing at the deleted session, resolveSession() in the
|
||||
// next sendMessage falls back to currentSession.id and targets a session
|
||||
// the backend has already deleted. The user-initiated delete path
|
||||
// (handleDeleteSession) does this clearing after the prune; pruneDeletedSession
|
||||
// itself must do the same so the SSE path is symmetric.
|
||||
const match = source.match(/pruneDeletedSession\(sessionID: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toMatch(
|
||||
/if \(this\.currentSession\?\.id === sessionID\)\s*\{[\s\S]*?this\.contextSessionID = undefined[\s\S]*?this\.setCurrentSession\(null\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("unfocuses the streams when the deleted id matches the focused session", () => {
|
||||
// Without this, connectionService.focused still reports the deleted id to
|
||||
// the backend (viewed.focused), and focusSession() never calls
|
||||
// unregisterFocused for this instance.
|
||||
const match = source.match(/pruneDeletedSession\(sessionID: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toMatch(/if \(this\.streams\.focused === sessionID\) this\.focusSession\(undefined\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendMessage / sendCommand draft id contract", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
|
||||
it("sendMessage mints a draftID when there is no current session and none was supplied", () => {
|
||||
// External session deletions leave currentSessionID() undefined and clear
|
||||
// draftSessionID(). Without minting a draftID here, the webview posts
|
||||
// {type: "sendMessage", sessionID: undefined, draftID: undefined} and the
|
||||
// extension's sessionCreated echo has no key to migrate the in-flight draft
|
||||
// from ":pending:<id>" to ":session:<newSessionId>". The user loses the
|
||||
// typed message and the new session starts empty.
|
||||
const body = extractFunctionBody(source, "sendMessage")
|
||||
expect(body).toMatch(/!sid && !draftID \? crypto\.randomUUID\(\) : draftID/)
|
||||
})
|
||||
|
||||
it("sendCommand mints a draftID when there is no current session and none was supplied", () => {
|
||||
const body = extractFunctionBody(source, "sendCommand")
|
||||
expect(body).toMatch(/!sid && !draftID \? crypto\.randomUUID\(\) : draftID/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PromptInput restoreFailed fallback contract", () => {
|
||||
const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx")
|
||||
const source = readFile(PROMPT_FILE)
|
||||
|
||||
it("targets draftKey() instead of computing a key from failed.sessionID", () => {
|
||||
// The contract: restoreFailed early-returns when userClearedSession is
|
||||
// true (covers BOTH "user clicked New Task" and the Delete-current-session
|
||||
// race window where currentSessionID/draftSessionID haven't been cleared
|
||||
// yet but userClearedSession is already true). When the user did NOT
|
||||
// explicitly clear, candidates come from the failure's sessionID/draftID
|
||||
// (the keys the send was actually scoped to), plus :new ONLY when the
|
||||
// user has effectively returned to the empty state via an external
|
||||
// session.deleted.
|
||||
const match = source.match(/const restoreFailed = \(failed: SendMessageFailedMessage\) => \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).not.toMatch(/const effectiveSessionID/)
|
||||
expect(match![1]).toMatch(/if \(session\.userClearedSession\(\)\) return/)
|
||||
expect(match![1]).toMatch(
|
||||
/if \(failed\.sessionID\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*sessionDraftKey\(failed\.sessionID\)\)\)/,
|
||||
)
|
||||
expect(match![1]).toMatch(
|
||||
/if \(failed\.draftID\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*pendingDraftKey\(failed\.draftID\)\)\)/,
|
||||
)
|
||||
expect(match![1]).toMatch(
|
||||
/if \(!session\.currentSessionID\(\) && !session\.draftSessionID\(\)\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*"new"\)\)/,
|
||||
)
|
||||
expect(match![1]).toMatch(/const target = draftKey\(\)/)
|
||||
expect(match![1]).toMatch(/candidates\.has\(target\)/)
|
||||
})
|
||||
|
||||
it("does NOT add :new when the user is on a different live session or pending draft", () => {
|
||||
// Guard against the unconditional-:new regression: if the user has
|
||||
// navigated to a different session/pending draft, the failed draft
|
||||
// must NOT be rehydrated into that unrelated prompt even if the
|
||||
// failure carries scope IDs that no longer match the live state.
|
||||
const match = source.match(/const restoreFailed = \(failed: SendMessageFailedMessage\) => \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).not.toMatch(
|
||||
/if \(!failed\.sessionID && !failed\.draftID\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*"new"\)\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionContext userClearedSession contract", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
|
||||
it("declares userClearedSession on the context interface", () => {
|
||||
// restoreFailed uses session.userClearedSession() to decide whether :new
|
||||
// is a legitimate restore target after the user clicks New Task or
|
||||
// deletes their current/draft session. The accessor must be exposed.
|
||||
expect(source).toMatch(/userClearedSession:\s*Accessor<boolean>/)
|
||||
})
|
||||
|
||||
it("clearCurrentSession sets the flag", () => {
|
||||
// User clicking New Task while a failure is pending must NOT restore
|
||||
// the failed draft into the new prompt.
|
||||
const body = extractFunctionBody(source, "clearCurrentSession")
|
||||
expect(body).toMatch(/setUserClearedSession\(true\)/)
|
||||
})
|
||||
|
||||
it("deleteSession sets the flag when deleting the current or draft session", () => {
|
||||
// User clicking Delete on their current/draft session is morally the
|
||||
// same as New Task — both land in :new without wanting a stale restore.
|
||||
const body = extractFunctionBody(source, "deleteSession")
|
||||
expect(body).toMatch(
|
||||
/if \(id === currentSessionID\(\) \|\| id === draftSessionID\(\)\) setUserClearedSession\(true\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("handleSessionCreated resets the flag when adopting the new session", () => {
|
||||
// After the user creates a new session, the flag is stale and must be
|
||||
// cleared so a later external delete of that new session can restore
|
||||
// into :new again.
|
||||
const body = extractFunctionBody(source, "handleSessionCreated")
|
||||
expect(body).toMatch(/setUserClearedSession\(false\)/)
|
||||
})
|
||||
|
||||
it("selectSession resets the flag when picking an existing session", () => {
|
||||
const body = extractFunctionBody(source, "selectSession")
|
||||
expect(body).toMatch(/setUserClearedSession\(false\)/)
|
||||
})
|
||||
|
||||
it("exposes userClearedSession in the SessionContext value", () => {
|
||||
expect(source).toMatch(/userClearedSession,?\s*\n\s*\}/m)
|
||||
})
|
||||
|
||||
it("sendMessage resets userClearedSession when starting a fresh draft from :new", () => {
|
||||
// Race: user on session A, sends, failure pending; clicks New Task
|
||||
// (userClearedSession=true), then types new text and clicks Send. We mint
|
||||
// a draftID and adopt it as draftSessionID. If a failure for the new
|
||||
// send returns BEFORE sessionCreated lands (so currentSessionID is
|
||||
// still undefined and userClearedSession is still true), the failure's
|
||||
// draftID matches draftSessionID() but the flag would suppress restore.
|
||||
// Resetting the flag at the moment the user starts the new draft closes
|
||||
// that window: the failure is for the current in-progress draft and must
|
||||
// be restorable.
|
||||
const body = extractFunctionBody(source, "sendMessage")
|
||||
const block = body.match(/if \(!sid\) \{([\s\S]*?)\}/)
|
||||
expect(block).not.toBeNull()
|
||||
expect(block![1]).toMatch(/setUserClearedSession\(false\)/)
|
||||
expect(block![1]).toMatch(/setDraftSessionID\(scope\)/)
|
||||
})
|
||||
|
||||
it("sendCommand resets userClearedSession when starting a fresh draft from :new", () => {
|
||||
const body = extractFunctionBody(source, "sendCommand")
|
||||
const block = body.match(/if \(!sid\) \{([\s\S]*?)\}/)
|
||||
expect(block).not.toBeNull()
|
||||
expect(block![1]).toMatch(/setUserClearedSession\(false\)/)
|
||||
expect(block![1]).toMatch(/setDraftSessionID\(scope\)/)
|
||||
})
|
||||
|
||||
it("selectCloudSession resets userClearedSession when picking a cloud session", () => {
|
||||
// After clearCurrentSession set the flag, selecting a cloud session
|
||||
// must clear it (mirrors selectSession's reset). Without this, any
|
||||
// post-import failure exits restoration early and loses the cleared
|
||||
// text, review comments, and images.
|
||||
const body = extractFunctionBody(source, "selectCloudSession")
|
||||
expect(body).toMatch(/setUserClearedSession\(false\)/)
|
||||
})
|
||||
|
||||
it("handleCloudSessionImported resets userClearedSession after the import completes", () => {
|
||||
// Defense in depth: even if selectCloudSession's reset was missed
|
||||
// (e.g. deleteSession set the flag against the synthetic cloud key
|
||||
// between select and import), the import confirmation must clear the
|
||||
// flag so a later post-import send failure is not suppressed.
|
||||
const body = extractFunctionBody(source, "handleCloudSessionImported")
|
||||
expect(body).toMatch(/setUserClearedSession\(false\)/)
|
||||
})
|
||||
|
||||
it("handleCloudSessionImported migrates draftSessionID from the cloud key to the real session id", () => {
|
||||
// Without this, draftSessionID stays on the synthetic "cloud:<id>" key.
|
||||
// After a later external delete of the imported session,
|
||||
// handleSessionDeleted only clears draftSessionID when it equals the
|
||||
// deleted id; the synthetic cloud key never matches, so draftKey()
|
||||
// falls back to ":pending:cloud:<id>" and restoreFailed can no longer
|
||||
// match :session:<id> or :new — silently losing the failed draft.
|
||||
const body = extractFunctionBody(source, "handleCloudSessionImported")
|
||||
expect(body).toMatch(/setDraftSessionID\(session\.id\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cloud import parts cleanup contract", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
|
||||
it("declares a pendingCloudPrune tracker for cloud message IDs", () => {
|
||||
// Without a tracker, repeated preview -> import cycles accumulate full
|
||||
// cloud transcripts in store.parts because handleMessagesLoaded never
|
||||
// knows which keys belong to the carried-over cloud messages.
|
||||
expect(source).toMatch(/pendingCloudPrune/)
|
||||
})
|
||||
|
||||
it("handleCloudSessionDataLoaded registers the cloud message IDs", () => {
|
||||
const body = extractFunctionBody(source, "handleCloudSessionDataLoaded")
|
||||
expect(body).toMatch(/pendingCloudPrune\.set\(/)
|
||||
})
|
||||
|
||||
it("handleCloudSessionImported transfers the prune set to the new session id", () => {
|
||||
const body = extractFunctionBody(source, "handleCloudSessionImported")
|
||||
expect(body).toMatch(/pendingCloudPrune\.set\(session\.id,/)
|
||||
expect(body).toMatch(/pendingCloudPrune\.delete\(cloudKey\)/)
|
||||
})
|
||||
|
||||
it("handleMessagesLoaded prunes cloud-import orphans from store.parts and stash", () => {
|
||||
// The carried-over cloud messages are gone from store.messages after
|
||||
// this call, so any store.parts[<cloud-msg-id>] entry is unreachable.
|
||||
const body = extractFunctionBody(source, "handleMessagesLoaded")
|
||||
expect(body).toMatch(/pendingCloudPrune\.get\(sessionID\)/)
|
||||
expect(body).toMatch(/pendingCloudPrune\.delete\(sessionID\)/)
|
||||
})
|
||||
|
||||
it("handleSessionDeleted prunes cloud-import orphans if the imported session is deleted before loadMessages returns", () => {
|
||||
const body = extractFunctionBody(source, "handleSessionDeleted")
|
||||
expect(body).toMatch(/pruneCloudOrphans\(sessionID\)/)
|
||||
})
|
||||
|
||||
it("handleCloudSessionImportFailed prunes cloud parts and the synthetic session entries", () => {
|
||||
// Implemented as a switch case inside handleExtensionMessage, not a
|
||||
// standalone function, so search the source for the case body directly.
|
||||
const idx = source.indexOf('case "cloudSessionImportFailed"')
|
||||
expect(idx).toBeGreaterThan(-1)
|
||||
const after = source.slice(idx, idx + 4000)
|
||||
expect(after).toMatch(/pruneCloudOrphans\(failedKey\)/)
|
||||
expect(after).toMatch(/delete sessions\[failedKey\]/)
|
||||
expect(after).toMatch(/delete messages\[failedKey\]/)
|
||||
})
|
||||
|
||||
it("handleCloudSessionImportFailed clears cloudPreviewId, currentSessionID, draftSessionID, and loading only when still on the failed cloud session", () => {
|
||||
// The failure arrives asynchronously. selectCloudSession sets the
|
||||
// preview id to the RAW cloud session id, both session/draft ids to
|
||||
// the synthetic "cloud:<id>" key, and the loading spinner, but the
|
||||
// user can start previewing a different cloud session, switch
|
||||
// sessions, or start a new task before the failure comes back.
|
||||
// Unconditionally resetting any of them would clobber that newer
|
||||
// scope: cloudPreviewId blanking drops a later preview response and
|
||||
// disables import-mode sends; currentSessionID blanking blanks
|
||||
// the active session; draftSessionID blanking leaves draftKey()
|
||||
// at ":new"; and unguarded setLoading(false) drops the spinner
|
||||
// for a newer preview before its data arrives, leaving the UI
|
||||
// looking idle while still loading. Clear only if still on the
|
||||
// dead preview's scope: cloudPreviewId is compared against the raw
|
||||
// message.cloudSessionId, while currentSessionID/draftSessionID are
|
||||
// compared against the "cloud:<id>" failedKey. The guard is
|
||||
// extracted into a clearIfOn helper to keep the switch-case
|
||||
// complexity under the lint cap.
|
||||
//
|
||||
// The loading check MUST run before cloudPreviewId is nulled,
|
||||
// otherwise `cloudPreviewId() === message.cloudSessionId` would be
|
||||
// false even on the failing preview and the spinner would stick
|
||||
// until later navigation clears it.
|
||||
const idx = source.indexOf('case "cloudSessionImportFailed"')
|
||||
expect(idx).toBeGreaterThan(-1)
|
||||
const after = source.slice(idx, idx + 4000)
|
||||
expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setLoading\(false\), message\.cloudSessionId\)/)
|
||||
expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setCloudPreviewId\(null\), message\.cloudSessionId\)/)
|
||||
expect(after).toMatch(/clearIfOn\(currentSessionID, \(\) => setCurrentSessionID\(undefined\), failedKey\)/)
|
||||
expect(after).toMatch(/clearIfOn\(draftSessionID, \(\) => setDraftSessionID\(undefined\), failedKey\)/)
|
||||
expect(after).not.toMatch(/^\s*setLoading\(false\)\s*$/m)
|
||||
// Loading check must come before cloudPreviewId null in the case body.
|
||||
const loadIdx = after.indexOf("setLoading(false)")
|
||||
const nullIdx = after.indexOf("setCloudPreviewId(null)")
|
||||
expect(loadIdx).toBeGreaterThan(-1)
|
||||
expect(nullIdx).toBeGreaterThan(-1)
|
||||
expect(loadIdx).toBeLessThan(nullIdx)
|
||||
})
|
||||
|
||||
it("clearIfOn runs the clear callback only while the scope still matches the key", () => {
|
||||
// Used by cloudSessionImportFailed so the switch case stays under the
|
||||
// complexity cap. The helper must compare get() to the key before
|
||||
// calling the clear callback: a stale async failure must not clobber
|
||||
// a newer scope the user has navigated to. Takes a clear callback
|
||||
// rather than a setter so the same helper works for both
|
||||
// undefined-cleared signals (currentSessionID / draftSessionID) and
|
||||
// null-cleared signals (cloudPreviewId) without changing their setter
|
||||
// signatures.
|
||||
let cleared = 0
|
||||
let value = "pending"
|
||||
clearIfOn(
|
||||
() => value,
|
||||
() => {
|
||||
cleared++
|
||||
},
|
||||
"pending",
|
||||
)
|
||||
expect(cleared).toBe(1)
|
||||
|
||||
// Scope has moved on (user navigated to a different preview / session)
|
||||
// — the clear callback must NOT run.
|
||||
value = "other"
|
||||
clearIfOn(
|
||||
() => value,
|
||||
() => {
|
||||
cleared++
|
||||
},
|
||||
"pending",
|
||||
)
|
||||
expect(cleared).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService pruneSession contract", () => {
|
||||
const source = readFile(CONNECTION_SERVICE_FILE)
|
||||
|
||||
it("drops the deleted session from focused and opened Maps", () => {
|
||||
// KiloProvider's pruneDeletedSession calls connectionService.pruneSession.
|
||||
// Without clearing focused/opened entries whose value is the deleted id,
|
||||
// the backend keeps receiving viewed.focused with the dead session id and
|
||||
// any background tab opener stays registered for it.
|
||||
const match = source.match(/pruneSession\(sessionId: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toMatch(/this\.focused\.delete\(key\)/)
|
||||
expect(match![1]).toMatch(/this\.opened\.(?:set|delete)/)
|
||||
expect(match![1]).toMatch(/this\.flushViewed\(\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -255,6 +255,17 @@ describe("SessionStreamScheduler / focus and lifecycle", () => {
|
||||
expect(stats.active).toBe(0)
|
||||
})
|
||||
|
||||
it("exposes the focused session id via the getter so pruneDeletedSession can match it", () => {
|
||||
const queue = new SessionStreamScheduler(() => {})
|
||||
expect(queue.focused).toBeUndefined()
|
||||
queue.focus("sess-1")
|
||||
expect(queue.focused).toBe("sess-1")
|
||||
queue.focus("sess-2")
|
||||
expect(queue.focused).toBe("sess-2")
|
||||
queue.focus(undefined)
|
||||
expect(queue.focused).toBeUndefined()
|
||||
})
|
||||
|
||||
it("dispose() stops further emissions from queued work", async () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
|
||||
@@ -57,16 +57,12 @@ import {
|
||||
scopeDraftKey,
|
||||
sessionDraftKey,
|
||||
} from "../../utils/prompt-drafts"
|
||||
import { drafts, imageDrafts, reviewDrafts } from "../../utils/draft-store"
|
||||
import { ReviewComments } from "./ReviewComments"
|
||||
import { partReview, reviewBody } from "../../../../src/shared/review-comments"
|
||||
import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
|
||||
|
||||
// Per-session input text storage (module-level so it survives remounts)
|
||||
const drafts = new Map<string, string>()
|
||||
const reviewDrafts = new Map<string, ReviewComment[]>()
|
||||
const imageDrafts = new Map<string, ImageAttachment[]>()
|
||||
const scrolls = new Map<string, number>()
|
||||
|
||||
function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] {
|
||||
if (incoming.length === 0) return current
|
||||
const map = new Map(current.map((item) => [item.id, item]))
|
||||
@@ -472,11 +468,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const restoreFailed = (failed: SendMessageFailedMessage) => {
|
||||
// Only restore a failed draft when the user has not started another one.
|
||||
const target = scopeDraftKey(
|
||||
boxKey(),
|
||||
sessionDraftKey(failed.sessionID) ?? pendingDraftKey(failed.draftID) ?? "new",
|
||||
)
|
||||
if (target !== draftKey() || text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0) return
|
||||
if (text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0) return
|
||||
|
||||
// If the user explicitly transitioned out of the original send's scope
|
||||
// (clearCurrentSession() or Delete on the current/draft session), don't
|
||||
// restore anywhere. This covers BOTH the obvious "user clicked New Task
|
||||
// and we land in :new" case AND the tighter race window where the user
|
||||
// clicked Delete on the current session: the backend's sessionDeleted
|
||||
// round-trip hasn't completed yet so currentSessionID/draftSessionID
|
||||
// still point at the dead session, but userClearedSession is true. Without
|
||||
// this guard, the session-scoped candidate on the previous lines would
|
||||
// match the still-current draftKey and rehydrate the failed draft into
|
||||
// the session the user explicitly chose to delete.
|
||||
if (session.userClearedSession()) return
|
||||
|
||||
// Build candidates from the keys the original send was actually scoped
|
||||
// under. :new is only added when the user has effectively returned to the
|
||||
// empty state — i.e. no current session and no pending draft. Combined
|
||||
// with the userClearedSession early return above, this catches both
|
||||
// "send from session -> session deleted mid-round-trip" and "send from
|
||||
// :new (mints draftID) -> session created mid-round-trip -> session
|
||||
// deleted externally" without rehydrating into any user-explicit clear.
|
||||
const candidates = new Set<string>()
|
||||
if (failed.sessionID) candidates.add(scopeDraftKey(boxKey(), sessionDraftKey(failed.sessionID)))
|
||||
if (failed.draftID) candidates.add(scopeDraftKey(boxKey(), pendingDraftKey(failed.draftID)))
|
||||
if (!session.currentSessionID() && !session.draftSessionID()) candidates.add(scopeDraftKey(boxKey(), "new"))
|
||||
const target = draftKey()
|
||||
if (!candidates.has(target)) return
|
||||
|
||||
const draft = failed.review ? reviewBody(failed.review, failed.text) : failed.text
|
||||
if (draft === undefined) return
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tracks cloud-import message IDs awaiting parts cleanup. During preview,
|
||||
* parts are stored keyed by original cloud message IDs (store.parts["<cloud-msg-id>"]).
|
||||
* On import, the carried-over messages keep those IDs until handleMessagesLoaded
|
||||
* replaces them with server-assigned IDs. Once the carried-over messages are gone
|
||||
* from store.messages, their parts are orphans — pruneCloudOrphans drops them so
|
||||
* preview -> import cycles don't accumulate full transcripts in the reactive store.
|
||||
*/
|
||||
import type { Part } from "../types/messages"
|
||||
|
||||
export interface PruneStash {
|
||||
remove: (id: string) => void
|
||||
}
|
||||
|
||||
export const createCloudPrune = (
|
||||
setParts: (mutator: (parts: Record<string, Part[]>) => void) => void,
|
||||
stash: PruneStash,
|
||||
) => {
|
||||
const pendingCloudPrune = new Map<string, Set<string>>()
|
||||
|
||||
const prune = (key: string) => {
|
||||
const ids = pendingCloudPrune.get(key)
|
||||
if (!ids) return
|
||||
setParts((parts) => {
|
||||
for (const id of ids) delete parts[id]
|
||||
})
|
||||
for (const id of ids) stash.remove(id)
|
||||
pendingCloudPrune.delete(key)
|
||||
}
|
||||
|
||||
return { pendingCloudPrune, prune }
|
||||
}
|
||||
|
||||
/** Clear a scope only if it still points at the given key. Async failure paths
|
||||
* must not clobber scopes the user has navigated to since the operation was
|
||||
* started. */
|
||||
export function clearIfOn<T>(get: () => T, clear: () => void, key: T) {
|
||||
if (get() === key) clear()
|
||||
}
|
||||
@@ -76,12 +76,21 @@ import { getVariant, sessionVariantKeys, transferVariants, variantKey } from "./
|
||||
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
|
||||
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
|
||||
import { visibleMessages as filterVisibleMessages } from "./session-queue"
|
||||
import { deleteDraftsForSession } from "../utils/draft-store"
|
||||
import { createAbortState } from "./abort-state"
|
||||
import { clearIfOn, createCloudPrune } from "./session-cloud-prune"
|
||||
import { isSameSessionTree } from "./model-usage"
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
const MESSAGE_PAGE_LIMIT = 80
|
||||
|
||||
/** Remove ids from a Set immutably, returning the original when nothing changed. */
|
||||
function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
|
||||
const next = new Set(prev)
|
||||
for (const id of ids) next.delete(id)
|
||||
return next.size === prev.size ? prev : next
|
||||
}
|
||||
|
||||
type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
|
||||
|
||||
interface MessagePageState {
|
||||
@@ -294,6 +303,7 @@ interface SessionContextValue {
|
||||
selectCloudSession: (cloudSessionId: string) => void
|
||||
draftSessionID: Accessor<string | undefined>
|
||||
setDraftSessionID: (id: string | undefined) => void
|
||||
userClearedSession: Accessor<boolean>
|
||||
}
|
||||
|
||||
export const SessionContext = createContext<SessionContextValue>()
|
||||
@@ -308,6 +318,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// Current session ID
|
||||
const [currentSessionID, setCurrentSessionID] = createSignal<string | undefined>()
|
||||
const [draftSessionID, setDraftSessionID] = createSignal<string | undefined>()
|
||||
const [userClearedSession, setUserClearedSession] = createSignal(false)
|
||||
|
||||
// Per-session status map — keyed by sessionID
|
||||
const [statusMap, setStatusMap] = createStore<Record<string, SessionStatusInfo>>({})
|
||||
@@ -547,6 +558,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
const agentNames = createMemo(() => new Set(agents().map((agent) => agent.name)))
|
||||
|
||||
const { pendingCloudPrune, prune: pruneCloudOrphans } = createCloudPrune((m) => setStore("parts", produce(m)), stash)
|
||||
|
||||
/** Per-mode model from config (e.g. config.agent.code.model). */
|
||||
function getModeModel(agentName: string): ModelSelection | null {
|
||||
return parseModelString(config().agent?.[agentName]?.model)
|
||||
@@ -1156,10 +1169,33 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
handleCloudSessionImported(message.cloudSessionId, message.session)
|
||||
break
|
||||
|
||||
case "cloudSessionImportFailed":
|
||||
setCloudPreviewId(null)
|
||||
setCurrentSessionID(undefined)
|
||||
setLoading(false)
|
||||
case "cloudSessionImportFailed": {
|
||||
const failedKey = `cloud:${message.cloudSessionId}`
|
||||
pruneCloudOrphans(failedKey)
|
||||
setStore(
|
||||
"sessions",
|
||||
produce((sessions) => {
|
||||
delete sessions[failedKey]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"messages",
|
||||
produce((messages) => {
|
||||
delete messages[failedKey]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"toolParts",
|
||||
produce((toolParts) => {
|
||||
delete toolParts[failedKey]
|
||||
}),
|
||||
)
|
||||
// cloudPreviewId stores the raw cloud session id (see selectCloudSession),
|
||||
// not the synthetic "cloud:<id>" key used for session/draft ids.
|
||||
clearIfOn(cloudPreviewId, () => setLoading(false), message.cloudSessionId)
|
||||
clearIfOn(cloudPreviewId, () => setCloudPreviewId(null), message.cloudSessionId)
|
||||
clearIfOn(currentSessionID, () => setCurrentSessionID(undefined), failedKey)
|
||||
clearIfOn(draftSessionID, () => setDraftSessionID(undefined), failedKey)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session",
|
||||
@@ -1167,6 +1203,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
console.error("[Kilo New] Cloud session import failed:", message.error)
|
||||
break
|
||||
}
|
||||
|
||||
case "worktreeStatsLoaded":
|
||||
setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions })
|
||||
@@ -1292,6 +1329,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
if (!draftID || draft === draftID || active === draftID) {
|
||||
setCurrentSessionID(session.id)
|
||||
setDraftSessionID(session.id)
|
||||
setUserClearedSession(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1484,6 +1522,19 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
const revert = store.sessions[sessionID]?.revert ?? undefined
|
||||
if (revert) resetTodos(sessionID, revert)
|
||||
recoverPrefs(sessionID, merged)
|
||||
|
||||
const cloudIDs = pendingCloudPrune.get(sessionID)
|
||||
if (cloudIDs?.size) {
|
||||
const live = new Set(messages.map((m) => m.id))
|
||||
setStore(
|
||||
"parts",
|
||||
produce((p) => {
|
||||
for (const id of cloudIDs) if (!live.has(id)) delete p[id]
|
||||
}),
|
||||
)
|
||||
for (const id of cloudIDs) stash.remove(id)
|
||||
pendingCloudPrune.delete(sessionID)
|
||||
}
|
||||
})
|
||||
if (reset) requestAnimationFrame(() => patchPage(sessionID, { lastMutation: undefined }))
|
||||
}
|
||||
@@ -1924,104 +1975,61 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
clearHiddenErrors(msgIds)
|
||||
|
||||
setStore(
|
||||
"sessions",
|
||||
produce((sessions) => {
|
||||
delete sessions[sessionID]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"messages",
|
||||
produce((messages) => {
|
||||
delete messages[sessionID]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"parts",
|
||||
produce((parts) => {
|
||||
for (const id of msgIds) {
|
||||
delete parts[id]
|
||||
produce((s) => {
|
||||
delete s.sessions[sessionID]
|
||||
delete s.messages[sessionID]
|
||||
for (const id of msgIds) delete s.parts[id]
|
||||
delete s.toolParts[sessionID]
|
||||
delete s.todos[sessionID]
|
||||
for (const [id, state] of Object.entries(s.modelUsage)) {
|
||||
if (id === sessionID || state.data?.sessionIDs.includes(sessionID)) delete s.modelUsage[id]
|
||||
}
|
||||
delete s.agentSelections[sessionID]
|
||||
delete s.sessionOverrides[sessionID]
|
||||
for (const key of sessionVariantKeys(s.variantSelections, sessionID)) delete s.variantSelections[key]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"toolParts",
|
||||
produce((parts) => {
|
||||
delete parts[sessionID]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"todos",
|
||||
produce((todos) => {
|
||||
delete todos[sessionID]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"modelUsage",
|
||||
produce((usage) => {
|
||||
for (const [id, state] of Object.entries(usage)) {
|
||||
if (id === sessionID || state.data?.sessionIDs.includes(sessionID)) delete usage[id]
|
||||
}
|
||||
}),
|
||||
)
|
||||
setPages(
|
||||
produce((map) => {
|
||||
delete map[sessionID]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"agentSelections",
|
||||
produce((selections) => {
|
||||
delete selections[sessionID]
|
||||
}),
|
||||
)
|
||||
// prettier-ignore
|
||||
setPages(produce((map) => { delete map[sessionID] }))
|
||||
// Clean up pending questions/errors for the deleted session
|
||||
const deleted = questions()
|
||||
.filter((q) => q.sessionID === sessionID)
|
||||
.map((q) => q.id)
|
||||
if (deleted.length > 0) {
|
||||
setQuestions((prev) => prev.filter((q) => q.sessionID !== sessionID))
|
||||
setQuestionErrors((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of deleted) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
setQuestionErrors((prev) => dropSet(prev, deleted))
|
||||
}
|
||||
const gone = suggestions()
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.map((item) => item.id)
|
||||
if (gone.length > 0) {
|
||||
setSuggestions((prev) => prev.filter((item) => item.sessionID !== sessionID))
|
||||
setSuggestionErrors((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
setRespondingSuggestions((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
setSuggestionErrors((prev) => dropSet(prev, gone))
|
||||
setRespondingSuggestions((prev) => dropSet(prev, gone))
|
||||
}
|
||||
const staleResponding = permissions()
|
||||
.filter((p) => p.sessionID === sessionID)
|
||||
.map((p) => p.id)
|
||||
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
|
||||
setStatusMap(
|
||||
produce((map) => {
|
||||
delete map[sessionID]
|
||||
}),
|
||||
)
|
||||
if (staleResponding.length > 0) {
|
||||
setRespondingPermissions((prev) => dropSet(prev, staleResponding))
|
||||
}
|
||||
// prettier-ignore
|
||||
setLoaded((prev) => { if (!prev.has(sessionID)) return prev; const next = new Set(prev); next.delete(sessionID); return next })
|
||||
// prettier-ignore
|
||||
setStatusMap(produce((map) => { delete map[sessionID] }))
|
||||
clearClose(sessionID)
|
||||
setBusySinceMap(
|
||||
produce((map) => {
|
||||
delete map[sessionID]
|
||||
}),
|
||||
)
|
||||
// prettier-ignore
|
||||
setBusySinceMap(produce((map) => { delete map[sessionID] }))
|
||||
if (currentSessionID() === sessionID) {
|
||||
setCurrentSessionID(undefined)
|
||||
setLoading(false)
|
||||
}
|
||||
// prettier-ignore
|
||||
if (draftSessionID() === sessionID) { setDraftSessionID(undefined) }
|
||||
})
|
||||
deleteDraftsForSession(sessionID)
|
||||
pruneCloudOrphans(sessionID)
|
||||
}
|
||||
|
||||
// Splices the message from the store and deletes its parts.
|
||||
@@ -2044,6 +2052,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
function handleCloudSessionDataLoaded(cloudSessionId: string, title: string, messages: Message[]) {
|
||||
if (cloudPreviewId() !== cloudSessionId) return
|
||||
const key = `cloud:${cloudSessionId}`
|
||||
pendingCloudPrune.set(key, new Set(messages.map((m) => m.id)))
|
||||
batch(() => {
|
||||
setLoaded((prev) => {
|
||||
if (prev.has(key)) return prev
|
||||
@@ -2093,31 +2102,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
setCloudPreviewId(null)
|
||||
setCurrentSessionID(session.id)
|
||||
setDraftSessionID(session.id)
|
||||
|
||||
setUserClearedSession(false)
|
||||
|
||||
// Clean up synthetic cloud: entries from sessions/messages stores.
|
||||
//
|
||||
// Why we do NOT delete cloud parts here:
|
||||
//
|
||||
// During preview, parts are stored keyed by the original cloud message IDs
|
||||
// (e.g. store.parts["<cloud-msg-id>"] = [...]). When the import completes
|
||||
// we carry cloudMessages into the new local session (above) so the UI
|
||||
// renders immediately without a loading flash. Those carried-over message
|
||||
// objects still hold their original cloud IDs, so every SessionTurn
|
||||
// calls getParts("<cloud-msg-id>") — which means the parts must remain in
|
||||
// the store for now.
|
||||
//
|
||||
// If we deleted them here, every message would temporarily render with no
|
||||
// parts (parts().length === 0), showing only a loading shimmer until the
|
||||
// real data arrives.
|
||||
//
|
||||
// Instead, right after this batch we dispatch a "loadMessages" request
|
||||
// (below). When the extension responds with the "messagesLoaded" event,
|
||||
// handleMessagesLoaded() replaces the messages array with server-assigned
|
||||
// IDs and writes new parts keyed by those IDs. The old cloud-keyed part
|
||||
// entries become orphans — no message in the store references them anymore.
|
||||
// They remain in store.parts until the webview reloads or the store is
|
||||
// reset, which is a bounded, one-session-worth amount of data that does
|
||||
// not accumulate over time.
|
||||
setStore(
|
||||
"sessions",
|
||||
produce((sessions) => {
|
||||
@@ -2137,6 +2125,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
const cloudPruneIDs = pendingCloudPrune.get(cloudKey)
|
||||
if (cloudPruneIDs) {
|
||||
pendingCloudPrune.set(session.id, cloudPruneIDs)
|
||||
pendingCloudPrune.delete(cloudKey)
|
||||
}
|
||||
// Load real messages in the background (picks up server-assigned IDs
|
||||
// and the new user message once the send completes via SSE)
|
||||
patchPage(session.id, { loadingInitial: true, before: undefined, hasMore: false })
|
||||
@@ -2263,12 +2256,16 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
dismissQuestion(q.id)
|
||||
}
|
||||
|
||||
const scope = draftID ?? sid
|
||||
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
|
||||
const scope = effectiveDraftID ?? sid
|
||||
if (scope) {
|
||||
clearClose(scope)
|
||||
addOptimistic(scope, messageID, text, files, review)
|
||||
startSubmission(scope, messageID)
|
||||
if (!sid) setDraftSessionID(scope)
|
||||
if (!sid) {
|
||||
setUserClearedSession(false)
|
||||
setDraftSessionID(scope)
|
||||
}
|
||||
}
|
||||
const agent = promptAgent(scope)
|
||||
|
||||
@@ -2277,7 +2274,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
text,
|
||||
messageID,
|
||||
sessionID: sid,
|
||||
draftID,
|
||||
draftID: effectiveDraftID,
|
||||
providerID,
|
||||
modelID,
|
||||
agent,
|
||||
@@ -2331,12 +2328,16 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
dismissQuestion(q.id)
|
||||
}
|
||||
|
||||
const scope = draftID ?? sid
|
||||
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
|
||||
const scope = effectiveDraftID ?? sid
|
||||
if (scope) {
|
||||
clearClose(scope)
|
||||
addOptimistic(scope, messageID, `/${command} ${args}`.trim(), files)
|
||||
startSubmission(scope, messageID)
|
||||
if (!sid) setDraftSessionID(scope)
|
||||
if (!sid) {
|
||||
setUserClearedSession(false)
|
||||
setDraftSessionID(scope)
|
||||
}
|
||||
}
|
||||
const agent = promptAgent(scope)
|
||||
|
||||
@@ -2346,7 +2347,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
arguments: args,
|
||||
messageID,
|
||||
sessionID: sid,
|
||||
draftID,
|
||||
draftID: effectiveDraftID,
|
||||
providerID,
|
||||
modelID,
|
||||
agent,
|
||||
@@ -2504,6 +2505,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
function clearCurrentSession() {
|
||||
setUserClearedSession(true)
|
||||
setCurrentSessionID(undefined)
|
||||
setDraftSessionID(undefined)
|
||||
setCloudPreviewId(null)
|
||||
@@ -2553,6 +2555,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// the worktree selection) still moved (the reported "only the diff changes").
|
||||
setCurrentSessionID(id)
|
||||
setDraftSessionID(id)
|
||||
setUserClearedSession(false)
|
||||
setLoading(!ready)
|
||||
if (!ready) patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false })
|
||||
// Only the message fetch needs the backend. Defer it while offline and let
|
||||
@@ -2599,6 +2602,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
setCloudPreviewId(cloudSessionId)
|
||||
setCurrentSessionID(key)
|
||||
setDraftSessionID(key)
|
||||
setUserClearedSession(false)
|
||||
setLoading(true)
|
||||
vscode.postMessage({ type: "requestCloudSessionData", sessionId: cloudSessionId })
|
||||
}
|
||||
@@ -2621,6 +2625,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
if (id === currentSessionID() || id === draftSessionID()) setUserClearedSession(true)
|
||||
vscode.postMessage({ type: "deleteSession", sessionID: id })
|
||||
}
|
||||
|
||||
@@ -2960,6 +2965,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
selectCloudSession,
|
||||
draftSessionID,
|
||||
setDraftSessionID,
|
||||
userClearedSession,
|
||||
}
|
||||
|
||||
return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider>
|
||||
|
||||
@@ -193,6 +193,7 @@ export function mockSessionValue(overrides?: {
|
||||
submitting: () => false,
|
||||
draftSessionID: () => undefined,
|
||||
setDraftSessionID: noop,
|
||||
userClearedSession: () => false,
|
||||
messageMutation: () => undefined,
|
||||
messages: () => [],
|
||||
visibleMessages: () => [],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReviewComment } from "../types/messages"
|
||||
import type { ImageAttachment } from "../hooks/useImageAttachments"
|
||||
|
||||
export const drafts = new Map<string, string>()
|
||||
export const reviewDrafts = new Map<string, ReviewComment[]>()
|
||||
export const imageDrafts = new Map<string, ImageAttachment[]>()
|
||||
|
||||
export function deleteDraftsForSession(id: string) {
|
||||
if (!id) return
|
||||
const sessionSuffix = `:session:${id}`
|
||||
const pendingSuffix = `:pending:${id}`
|
||||
const maps = [drafts, reviewDrafts, imageDrafts]
|
||||
for (const map of maps) {
|
||||
for (const key of map.keys()) {
|
||||
if (typeof key !== "string") continue
|
||||
if (key.endsWith(sessionSuffix) || key.endsWith(pendingSuffix)) {
|
||||
map.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user