From da81858802127cb8bb8ed2deaa1989793b356adf Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 01:29:23 +0800 Subject: [PATCH] fix(web): clear all per-session state when archiving or removing a session (#984) * 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 --- .changeset/fix-web-forget-session.md | 5 ++ apps/kimi-web/src/api/daemon/ws.ts | 4 ++ .../composables/client/useWorkspaceState.ts | 6 +-- .../src/composables/useKimiWebClient.ts | 51 +++++++++++++------ 4 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-web-forget-session.md diff --git a/.changeset/fix-web-forget-session.md b/.changeset/fix-web-forget-session.md new file mode 100644 index 000000000..c175aed4e --- /dev/null +++ b/.changeset/fix-web-forget-session.md @@ -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. diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts index b59e2f238..ab4fd15eb 100644 --- a/apps/kimi-web/src/api/daemon/ws.ts +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -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', diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 54232f260..c590e5e4f 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -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; diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 33d58de50..1f04a3f56 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -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 { - 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,