fix(chat): converge archived unread on chat:session_updated realtime event (MUL-4360)

Howard's #5333 review found a real cross-tab gap: the chat:session_updated
handler patched status but never unread, and chatSessionsOptions is
staleTime: Infinity, so a session archived in one tab kept its unread badge lit
in another tab/device forever — the same stuck-badge bug, one surface over.

Extract the inline handler into applyChatSessionUpdatedToCache and force
unread_count=0 / has_unread=false when payload.status === "archived", mirroring
the archive mutation's optimistic patch and the backend deriving unread=0 for
archived rows. Unarchive does NOT fabricate a count — the true unread returns
from the server refetch (last_read_at untouched). No sessions-list
invalidation; minimal field patch as reviewed.

Adds use-realtime-sync.test.ts coverage: an archived event zeroes a cached
unread row; an active event does not resurrect unread; a rename-only event
leaves unread untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-13 16:51:42 +08:00
parent 2d4b9c36e3
commit 6c232cc216
2 changed files with 141 additions and 32 deletions

View File

@@ -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> = {}): 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<ChatSession[]>(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<ChatSession[]>(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<ChatSession[]>(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<ChatSession[]>(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<ChatSession[]>(chatKeys.sessions(WS_ID), [makeSession()]);
applyChatSessionUpdatedToCache(qc, WS_ID, {
chat_session_id: "s1",
title: "Renamed",
});
const row = qc.getQueryData<ChatSession[]>(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();

View File

@@ -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<ChatSession[]>(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<ChatSession[]>(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