mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user