Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-web-unread-cross-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the web sidebar's unread dots getting out of sync across browser tabs.
81 changes: 42 additions & 39 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, boolean> {
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<string, boolean> = {};
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
if (value === true) out[id] = true;
}
return out;
} catch {
return {};
}
}

function saveUnreadToStorage(map: Record<string, boolean>): void {
try {
// Store only the `true` entries so the key stays compact (cleared sessions
// write `false` and are dropped here).
const out: Record<string, boolean> = {};
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);
Expand Down Expand Up @@ -384,7 +349,7 @@ const rawState: ExtendedState = reactive({
gitStatusBySession: {},
promptIdBySession: {},
sendingBySession: {},
unreadBySession: loadUnreadFromStorage(),
unreadBySession: loadUnread(),
authReady: false,
defaultModel: null,
managedProviderStatus: null,
Expand All @@ -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<AppModel[]>([]);
const starredModelIds = ref<string[]>(loadStarredModelsFromStorage());
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
39 changes: 39 additions & 0 deletions apps/kimi-web/src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> {
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<string, boolean> = {};
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
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<string, boolean>): void {
const current = loadUnread();
const merged: Record<string, boolean> = { ...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));
}
34 changes: 34 additions & 0 deletions apps/kimi-web/test/storage-logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
loadUnread,
saveUnread,
STORAGE_KEYS,
draftStorageKey,
safeGetJson,
Expand Down Expand Up @@ -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 });
});
});
Loading