diff --git a/packages/core/realtime/use-realtime-sync.test.ts b/packages/core/realtime/use-realtime-sync.test.ts index a3e3869309..4eb6bbed5b 100644 --- a/packages/core/realtime/use-realtime-sync.test.ts +++ b/packages/core/realtime/use-realtime-sync.test.ts @@ -12,11 +12,13 @@ import type { ChatMessage, ChatPendingTask, ChatMessagesPage, + ChatSession, InboxItem, Workspace, } from "../types"; import { applyChatDoneToCache, + applyChatSessionUpdatedToCache, applyWorkspaceUpdatedToCache, handleInboxNew, invalidateChatMessageQueries, @@ -153,6 +155,87 @@ describe("applyChatDoneToCache", () => { }); }); +describe("applyChatSessionUpdatedToCache", () => { + const WS_ID = "ws-1"; + + function makeSession(overrides: Partial = {}): ChatSession { + return { + id: "s1", + workspace_id: WS_ID, + agent_id: "agent-1", + creator_id: "user-1", + title: "Session 1", + status: "active", + has_unread: true, + unread_count: 2, + created_at: "2026-07-10T00:00:00Z", + updated_at: "2026-07-10T00:00:00Z", + ...overrides, + }; + } + + // MUL-4360 cross-tab: chatSessionsOptions is staleTime: Infinity, so a stale + // cache in another tab never self-heals. When an archive event lands there, + // the row's unread must be forced to 0 to match the archive mutation and the + // backend, or the sidebar/header keep counting an archived session no one can + // open — the same stuck badge, one surface over. + it("zeroes unread when a session_updated event archives a cached unread row", () => { + const qc = createQueryClient(); + qc.setQueryData(chatKeys.sessions(WS_ID), [makeSession()]); + + applyChatSessionUpdatedToCache(qc, WS_ID, { + chat_session_id: "s1", + status: "archived", + updated_at: "2026-07-10T01:00:00Z", + }); + + const row = qc.getQueryData(chatKeys.sessions(WS_ID))![0]!; + expect(row.status).toBe("archived"); + expect(row.unread_count).toBe(0); + expect(row.has_unread).toBe(false); + }); + + // Unarchive must NOT fabricate unread — the true state comes back from the + // server refetch (last_read_at is untouched). An `active` status event leaves + // the row's unread fields exactly as cached, so a previously-zeroed archived + // row stays at 0 rather than being resurrected out of thin air. + it("does not resurrect unread when a session_updated event reactivates a row", () => { + const qc = createQueryClient(); + qc.setQueryData(chatKeys.sessions(WS_ID), [ + makeSession({ status: "archived", has_unread: false, unread_count: 0 }), + ]); + + applyChatSessionUpdatedToCache(qc, WS_ID, { + chat_session_id: "s1", + status: "active", + updated_at: "2026-07-10T02:00:00Z", + }); + + const row = qc.getQueryData(chatKeys.sessions(WS_ID))![0]!; + expect(row.status).toBe("active"); + expect(row.unread_count).toBe(0); + expect(row.has_unread).toBe(false); + }); + + // A plain rename carries neither status nor pinned; it must not touch unread + // (a live active session keeps its real unread count) or re-sort. + it("leaves unread untouched on a rename-only event", () => { + const qc = createQueryClient(); + qc.setQueryData(chatKeys.sessions(WS_ID), [makeSession()]); + + applyChatSessionUpdatedToCache(qc, WS_ID, { + chat_session_id: "s1", + title: "Renamed", + }); + + const row = qc.getQueryData(chatKeys.sessions(WS_ID))![0]!; + expect(row.title).toBe("Renamed"); + expect(row.status).toBe("active"); + expect(row.unread_count).toBe(2); + expect(row.has_unread).toBe(true); + }); +}); + describe("invalidateChatMessageQueries", () => { it("invalidates both legacy and paged chat message caches", () => { const qc = createQueryClient(); diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index dc4b6943da..3586b65003 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -184,6 +184,58 @@ function patchLatestChatMessagePage( }; } +type ChatSessionUpdatedPayload = { + chat_session_id: string; + title?: string; + pinned?: boolean; + status?: "active" | "archived"; + updated_at?: string; +}; + +/** + * Patch the cached sessions row for a `chat:session_updated` event (rename, + * pin/unpin, archive/unarchive from any tab/device) instead of refetching the + * whole list. `pinned` is present only on pin/unpin events and `status` only on + * archive/unarchive; a plain rename omits both, so absent fields leave existing + * state untouched. When either changes we re-sort so the row lands in the right + * place (pin → top; archive → the other list) like the server order. + * + * Archiving MUST also zero the row's unread here: the server payload carries + * only status/updated_at, and chatSessionsOptions is `staleTime: Infinity`, so a + * stale cache in another tab/device would otherwise keep an archived session's + * unread badge lit forever — the same MUL-4360 stuck-badge bug, one surface over. + * This mirrors the archive mutation's optimistic patch and the backend deriving + * unread_count=0 for archived rows. Unarchive does NOT fabricate a count — the + * true unread state comes back from the server refetch (last_read_at is + * untouched), so we leave the row's unread fields as-is for `active`. + */ +export function applyChatSessionUpdatedToCache( + qc: QueryClient, + wsId: string, + payload: ChatSessionUpdatedPayload, +): void { + qc.setQueryData(chatKeys.sessions(wsId), (old) => { + if (!old) return old; + const next = old.map((s) => + s.id === payload.chat_session_id + ? { + ...s, + title: payload.title ?? s.title, + pinned: payload.pinned ?? s.pinned, + status: payload.status ?? s.status, + updated_at: payload.updated_at ?? s.updated_at, + ...(payload.status === "archived" + ? { unread_count: 0, has_unread: false } + : {}), + } + : s, + ); + return payload.pinned === undefined && payload.status === undefined + ? next + : sortChatSessions(next); + }); +} + /** * Apply a workspace:updated event to the cache. Always refreshes the * workspace list. If the incoming `issue_prefix` differs from what's @@ -1123,42 +1175,16 @@ export function useRealtimeSync( invalidateSessionLists(); }); - // chat:session_updated fires after the creator renames a session in - // any tab/device. Patch the cached row inline so the dropdown reflects - // the new title without a full sessions-list refetch. + // chat:session_updated fires after the creator renames, pins, or archives + // a session in any tab/device. Patch the cached row inline so the dropdown + // and badges reflect the change without a full sessions-list refetch — see + // applyChatSessionUpdatedToCache for why archive must also zero unread. const unsubChatSessionUpdated = ws.on("chat:session_updated", (p) => { - const payload = p as { - chat_session_id: string; - title?: string; - pinned?: boolean; - status?: "active" | "archived"; - updated_at?: string; - }; + const payload = p as ChatSessionUpdatedPayload; chatWsLogger.info("chat:session_updated (global)", payload); const id = getCurrentWsId(); if (!id) return; - // `pinned` is present only on pin/unpin events and `status` only on - // archive/unarchive; a plain rename omits both, so leave the existing - // state untouched then. When either changes we re-sort so the row lands - // in the right place (pin → top; archive → the other list) like the - // server order. - qc.setQueryData(chatKeys.sessions(id), (old) => { - if (!old) return old; - const next = old.map((s) => - s.id === payload.chat_session_id - ? { - ...s, - title: payload.title ?? s.title, - pinned: payload.pinned ?? s.pinned, - status: payload.status ?? s.status, - updated_at: payload.updated_at ?? s.updated_at, - } - : s, - ); - return payload.pinned === undefined && payload.status === undefined - ? next - : sortChatSessions(next); - }); + applyChatSessionUpdatedToCache(qc, id, payload); }); // chat:session_deleted fires after a hard delete. The originating tab has