diff --git a/.changeset/web-session-subscription-cap.md b/.changeset/web-session-subscription-cap.md new file mode 100644 index 0000000000..bf293e41ae --- /dev/null +++ b/.changeset/web-session-subscription-cap.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web UI becoming sluggish after opening many sessions. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 033fe1f68f..2bfce976ef 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -118,7 +118,7 @@ export interface UseWorkspaceStateDeps { nextOptimisticMsgId: () => string; getEventConn: () => KimiEventConnection | null; syncSessionFromSnapshot: (sessionId: string) => Promise; - subscribeToSessionEvents: (sessionId: string) => void; + reopenSession: (sessionId: string) => Promise; hasLoadedMessages: (sessionId: string) => boolean; refreshSessionStatus: (sessionId: string) => Promise; persistSessionProfile: (patch: PersistSessionProfilePatch) => void; @@ -166,7 +166,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta nextOptimisticMsgId, getEventConn, syncSessionFromSnapshot, - subscribeToSessionEvents, + reopenSession, hasLoadedMessages, refreshSessionStatus, persistSessionProfile, @@ -959,9 +959,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = await syncSessionFromSnapshot(sessionId); if (result === 'not-found') return; } else { - // Re-open: resume from the tracked cursor; the daemon replays any - // missed durable events (or answers resync_required → snapshot). - subscribeToSessionEvents(sessionId); + // Re-open: if the session was evicted from the subscription cap since + // last open, reopenSession rebuilds it from a snapshot (the kept cursor + // may have skipped per-session events); otherwise it re-subscribes from + // the tracked cursor and the daemon replays any missed durable events. + const result = await reopenSession(sessionId); + if (result === 'not-found') return; } // Refresh sidecars AFTER the snapshot settles so status/usage updates diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 5791758c49..2bf6428f32 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -503,6 +503,7 @@ function forgetSession(sessionId: string): void { // buffered event for this id would otherwise be reduced and recreate the very // per-session maps we are about to delete. eventConn?.unsubscribe(sessionId); + dropWsSubscription(sessionId); // Drain the streaming-event batcher too. unsubscribe() stops future server // frames, but events already queued for the next animation frame would // otherwise survive and be reduced AFTER the maps below are cleared — @@ -1158,7 +1159,9 @@ async function syncSessionFromSnapshot(sessionId: string): Promise(); + +function retainWsSubscription(sessionId: string): void { + const idx = wsSubscriptionOrder.indexOf(sessionId); + if (idx !== -1) wsSubscriptionOrder.splice(idx, 1); + wsSubscriptionOrder.unshift(sessionId); + // Evict the oldest entries past the cap, skipping the active session. The + // active session is NOT guaranteed to sit at the front: first-time opens only + // retain after an awaited snapshot, so rapid clicks can complete out of order + // and leave the active session at the tail. Skipping it (rather than breaking + // when the tail is active) keeps the cap effective. + while (wsSubscriptionOrder.length > MAX_WS_SUBSCRIPTIONS) { + let victimIdx = -1; + for (let i = wsSubscriptionOrder.length - 1; i >= 0; i--) { + if (wsSubscriptionOrder[i] !== rawState.activeSessionId) { + victimIdx = i; + break; + } + } + if (victimIdx === -1) break; + const [victim] = wsSubscriptionOrder.splice(victimIdx, 1); + if (victim === undefined) break; + eventConn?.unsubscribe(victim); + sessionsWithStaleCursor.add(victim); + } +} + +function dropWsSubscription(sessionId: string): void { + const idx = wsSubscriptionOrder.indexOf(sessionId); + if (idx !== -1) wsSubscriptionOrder.splice(idx, 1); + sessionsWithStaleCursor.delete(sessionId); +} + function subscribeToSessionEvents(sessionId: string): void { connectEventsIfNeeded(); if (eventConn) { @@ -1192,7 +1254,27 @@ function subscribeToSessionEvents(sessionId: string): void { const seq = rawState.lastSeqBySession[sessionId] ?? 0; const epoch = epochBySession[sessionId]; eventConn.subscribe(sessionId, { seq, epoch }); + retainWsSubscription(sessionId); + } +} + +/** Re-open an already-loaded session. If it was evicted from the subscription + * cap since it was last open, rebuild from a snapshot: resuming from the kept + * cursor would skip the per-session events that arrived while unsubscribed, + * and replaying from seq 0 would make the projector regenerate message ids and + * duplicate the already-loaded transcript. Otherwise just re-subscribe from the + * tracked cursor. + * + * The stale marker is only read here; `syncSessionFromSnapshot` clears it once + * the snapshot succeeds. If the snapshot fails transiently the marker stays, so + * the next re-open retries the snapshot instead of falling back to a cursor + * that may have skipped events while unsubscribed. */ +async function reopenSession(sessionId: string): Promise { + if (sessionsWithStaleCursor.has(sessionId)) { + return syncSessionFromSnapshot(sessionId); } + subscribeToSessionEvents(sessionId); + return 'ok'; } // --------------------------------------------------------------------------- @@ -2120,7 +2202,7 @@ const workspaceState = useWorkspaceState(rawState, { nextOptimisticMsgId, getEventConn: () => eventConn, syncSessionFromSnapshot, - subscribeToSessionEvents, + reopenSession, hasLoadedMessages, refreshSessionStatus, persistSessionProfile,