diff --git a/.changeset/fix-web-unread-cross-tab.md b/.changeset/fix-web-unread-cross-tab.md new file mode 100644 index 0000000000..21b66547b8 --- /dev/null +++ b/.changeset/fix-web-unread-cross-tab.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web sidebar's unread dots getting out of sync across browser tabs. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 022aa04bcd..d63ec7084e 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -6,7 +6,7 @@ import { computed, reactive, ref, watch } from 'vue'; import { i18n } from '../i18n'; import { getKimiWebApi } from '../api'; import { isDaemonApiError, isDaemonNetworkError } from '../api/errors'; -import { safeGetString, safeRemove, safeSetString, STORAGE_KEYS } from '../lib/storage'; +import { loadUnread, safeGetString, safeRemove, safeSetString, saveUnread, STORAGE_KEYS } from '../lib/storage'; import { useAppearance } from './client/useAppearance'; import { useNotification } from './client/useNotification'; @@ -78,7 +78,6 @@ const PLAN_MODE_STORAGE_KEY = STORAGE_KEYS.planMode; const SWARM_MODE_STORAGE_KEY = STORAGE_KEYS.swarmMode; const GOAL_MODE_STORAGE_KEY = STORAGE_KEYS.goalMode; const STARRED_MODELS_STORAGE_KEY = STORAGE_KEYS.starredModels; -const UNREAD_STORAGE_KEY = STORAGE_KEYS.unread; const SESSION_NOT_FOUND_CODE = 40401; const PROMPT_NOT_FOUND_CODE = 40402; const ONBOARDED_STORAGE_KEY = STORAGE_KEYS.onboarded; @@ -177,40 +176,6 @@ function saveGoalModeToStorage(v: boolean): void { } } -// Per-session unread flags are pure client state (set when a background turn -// finishes, cleared on open). Persisting the `true` entries lets them survive a -// page refresh — without this the sidebar's unread dots vanish on reload because -// the in-memory map starts empty and there is no server-side read cursor. -function loadUnreadFromStorage(): Record { - try { - const raw = safeGetString(UNREAD_STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object') return {}; - const out: Record = {}; - for (const [id, value] of Object.entries(parsed as Record)) { - if (value === true) out[id] = true; - } - return out; - } catch { - return {}; - } -} - -function saveUnreadToStorage(map: Record): void { - try { - // Store only the `true` entries so the key stays compact (cleared sessions - // write `false` and are dropped here). - const out: Record = {}; - for (const [id, value] of Object.entries(map)) { - if (value) out[id] = true; - } - safeSetString(UNREAD_STORAGE_KEY, JSON.stringify(out)); - } catch { - // ignore - } -} - function loadStarredModelsFromStorage(): string[] { try { const raw = safeGetString(STARRED_MODELS_STORAGE_KEY); @@ -384,7 +349,7 @@ const rawState: ExtendedState = reactive({ gitStatusBySession: {}, promptIdBySession: {}, sendingBySession: {}, - unreadBySession: loadUnreadFromStorage(), + unreadBySession: loadUnread(), authReady: false, defaultModel: null, managedProviderStatus: null, @@ -403,6 +368,44 @@ const rawState: ExtendedState = reactive({ messagesLoadMoreErrorBySession: {}, }); +// Cross-tab sync: when another tab writes the unread key, adopt its value so a +// clear on one tab doesn't get overwritten by this tab's stale in-memory map. +// +// The session this tab is actively viewing is also cleared (only while visible): +// its unread bit may have been set by a tab where it was in the background, and +// we don't want the on-screen session to light up a dot. The same clear runs when +// a hidden tab becomes visible again, so a dot that arrived while hidden is +// dropped once the user is actually looking. +function clearActiveUnread(): void { + const active = rawState.activeSessionId; + if ( + active && + rawState.unreadBySession[active] && + typeof document !== 'undefined' && + document.visibilityState === 'visible' + ) { + rawState.unreadBySession = { ...rawState.unreadBySession, [active]: false }; + saveUnread({ [active]: false }); + } +} + +if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + if (event.key === STORAGE_KEYS.unread) { + rawState.unreadBySession = loadUnread(); + clearActiveUnread(); + } + }); +} + +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + clearActiveUnread(); + } + }); +} + // Models + Providers reactive state (lazy-loaded, cached) const models = ref([]); const starredModelIds = ref(loadStarredModelsFromStorage()); @@ -2326,7 +2329,7 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void { // excluded on purpose: there is no fresh result to read, and counting them // is what made the sidebar fill with stale unreads after a refresh. rawState.unreadBySession = { ...rawState.unreadBySession, [sid]: true }; - saveUnreadToStorage(rawState.unreadBySession); + saveUnread({ [sid]: true }); } // Browser notification when the user isn't watching this session. @@ -2904,7 +2907,7 @@ async function selectSession( // Opening a session clears its unread dot. if (rawState.unreadBySession[sessionId]) { rawState.unreadBySession = { ...rawState.unreadBySession, [sessionId]: false }; - saveUnreadToStorage(rawState.unreadBySession); + saveUnread({ [sessionId]: false }); } // A diff belongs to the session it was loaded from — drop it on switch. clearFileDiff(); diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index c45bfab02f..a0241936e2 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -82,3 +82,42 @@ export function safeSetJson(key: string, value: unknown): void { // ignore } } + +/** + * Per-session unread flags: a session id is "unread" when its value is `true`. + * Persisted as a compact map of only the `true` entries (cleared sessions are + * dropped). Backed by a single localStorage key so the sidebar's unread dots + * survive a page refresh — there is no server-side read cursor. + */ +export function loadUnread(): Record { + const raw = safeGetString(STORAGE_KEYS.unread); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') return {}; + const out: Record = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + if (value === true) out[id] = true; + } + return out; + } catch { + return {}; + } +} + +/** + * Apply a partial set of unread changes on top of the latest stored value. + * Passing only the changed entries (rather than a full in-memory map) is what + * keeps a clear that landed from another tab from being overwritten by this + * tab's stale state. A `true` entry marks the session unread; a `false` entry + * deletes the key (clearing the unread dot). + */ +export function saveUnread(changes: Record): void { + const current = loadUnread(); + const merged: Record = { ...current }; + for (const [id, value] of Object.entries(changes)) { + if (value) merged[id] = true; + else delete merged[id]; + } + safeSetString(STORAGE_KEYS.unread, JSON.stringify(merged)); +} diff --git a/apps/kimi-web/test/storage-logic.test.ts b/apps/kimi-web/test/storage-logic.test.ts index 4c72223291..1983447cea 100644 --- a/apps/kimi-web/test/storage-logic.test.ts +++ b/apps/kimi-web/test/storage-logic.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + loadUnread, + saveUnread, STORAGE_KEYS, draftStorageKey, safeGetJson, @@ -135,3 +137,35 @@ describe('STORAGE_KEYS', () => { expect(STORAGE_KEYS.locale).toBe('kimi-locale'); }); }); + +describe('loadUnread / saveUnread', () => { + it('returns an empty map when the key is missing', () => { + expect(loadUnread()).toEqual({}); + }); + + it('keeps only true entries', () => { + safeSetString(STORAGE_KEYS.unread, JSON.stringify({ B: true, C: false, D: 'yes' })); + expect(loadUnread()).toEqual({ B: true }); + }); + + it('drops false entries, clearing the unread dot', () => { + saveUnread({ B: true, C: true }); + saveUnread({ B: false }); + expect(loadUnread()).toEqual({ C: true }); + }); + + it('merges with the latest stored value so a clear from another tab is not overwritten', () => { + // This tab marks B unread. + saveUnread({ B: true }); + expect(loadUnread()).toEqual({ B: true }); + + // Another tab clears B and marks C (simulated by writing the key directly). + safeSetString(STORAGE_KEYS.unread, JSON.stringify({ C: true })); + + // This tab marks D unread, passing only the change (not a full, stale map). + saveUnread({ D: true }); + + // B must NOT come back — it was cleared by the other tab. + expect(loadUnread()).toEqual({ C: true, D: true }); + }); +});