fix(web): clear all per-session state when archiving or removing a session (#984)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* fix(web): clear all per-session state when archiving or removing a session

* fix(web): clear queued and in-flight prompt state in session teardown

* fix(web): unsubscribe session before clearing its state in teardown

* fix(web): cancel pending WS subscriptions on unsubscribe
This commit is contained in:
qer 2026-06-23 01:29:23 +08:00 committed by GitHub
parent 8c6cade69e
commit da81858802
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 48 additions and 18 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Clear all per-session state when a session is archived or removed, so archived sessions no longer leave orphaned data behind.

View file

@ -160,6 +160,10 @@ export class DaemonEventSocket {
/** Unsubscribe from a session's events. */
unsubscribe(sessionId: string): void {
this.subscriptions.delete(sessionId);
// Also cancel a subscribe that was queued before server_hello; otherwise
// onServerHello would merge it back into the active subscription set.
const pendingIdx = this.pendingSubscriptions.findIndex((p) => p.sessionId === sessionId);
if (pendingIdx !== -1) this.pendingSubscriptions.splice(pendingIdx, 1);
if (this.connected && this.ws) {
this.send({
type: 'unsubscribe',

View file

@ -64,7 +64,7 @@ export interface UseWorkspaceStateDeps {
updateSession: (id: string, update: (session: AppSession) => AppSession) => void;
upsertSessionFront: (session: AppSession) => void;
appendSession: (session: AppSession) => void;
removeSession: (id: string) => void;
forgetSession: (id: string) => void;
setActiveSessionId: (id: string | undefined) => void;
/** Update one session's message list via a function of the current list. */
updateSessionMessages: (
@ -110,7 +110,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
updateSession,
upsertSessionFront,
appendSession,
removeSession,
forgetSession,
setActiveSessionId,
updateSessionMessages,
nextOptimisticMsgId,
@ -1322,7 +1322,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
try {
const api = getKimiWebApi();
await api.archiveSession(id);
removeSession(id);
forgetSession(id);
sideChat.clearSideChatForSession(id);
const { [id]: _removedIds, ...restIds } = rawState.sideChatUserMessageIdsBySession;
void _removedIds;

View file

@ -442,6 +442,40 @@ function removeSessionMessages(sessionId: string): void {
rawState.messagesBySession = rest;
}
// ---------------------------------------------------------------------------
// Session teardown — single place that wipes a session and all its per-session
// sidecar state. Both removal entry points (not-found + archive) go through
// this, so adding a new per-session map only ever needs one new line here.
// ---------------------------------------------------------------------------
function forgetSession(sessionId: string): void {
// Stop receiving events for this session BEFORE clearing its state: a late or
// buffered event for this id would otherwise be reduced and recreate the very
// per-session maps we are about to delete.
eventConn?.unsubscribe(sessionId);
removeSession(sessionId);
removeSessionMessages(sessionId);
delete rawState.approvalsBySession[sessionId];
delete rawState.questionsBySession[sessionId];
delete rawState.tasksBySession[sessionId];
delete rawState.goalBySession[sessionId];
delete rawState.gitStatusBySession[sessionId];
delete rawState.lastSeqBySession[sessionId];
delete rawState.compactionBySession[sessionId];
delete rawState.messagesLoadingMoreBySession[sessionId];
delete rawState.messagesHasMoreBySession[sessionId];
delete rawState.messagesLoadMoreErrorBySession[sessionId];
delete epochBySession[sessionId];
sessionsKnownEmpty.delete(sessionId);
// In-flight / queued prompt state: drop these too so a queued follow-up
// can't be submitted to a session that was just archived when its turn later
// goes idle (onSessionIdle drains queuedBySession[sid] without re-checking
// that the session still exists).
inFlightPromptSessions.delete(sessionId);
delete rawState.queuedBySession[sessionId];
delete rawState.promptIdBySession[sessionId];
delete rawState.sendingBySession[sessionId];
}
// Models + Providers reactive state and helpers live in
// ./client/useModelProviderState. It is instantiated below (after the
// `activity` computed it depends on) as `modelProvider`.
@ -894,20 +928,7 @@ function goalErrorMessage(err: unknown): string | undefined {
}
async function handleSessionNotFound(sessionId: string): Promise<void> {
removeSession(sessionId);
removeSessionMessages(sessionId);
delete rawState.approvalsBySession[sessionId];
delete rawState.questionsBySession[sessionId];
delete rawState.tasksBySession[sessionId];
delete rawState.goalBySession[sessionId];
delete rawState.gitStatusBySession[sessionId];
delete rawState.lastSeqBySession[sessionId];
delete rawState.compactionBySession[sessionId];
delete rawState.messagesLoadingMoreBySession[sessionId];
delete rawState.messagesHasMoreBySession[sessionId];
delete rawState.messagesLoadMoreErrorBySession[sessionId];
delete epochBySession[sessionId];
sessionsKnownEmpty.delete(sessionId);
forgetSession(sessionId);
if (rawState.activeSessionId !== sessionId) return;
@ -1835,7 +1856,7 @@ const workspaceState = useWorkspaceState(rawState, {
updateSession,
upsertSessionFront,
appendSession,
removeSession,
forgetSession,
setActiveSessionId,
updateSessionMessages,
nextOptimisticMsgId,