mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
fix(chat): zero unread for archived chat sessions across all badges (MUL-4360)
Archiving a chat session flips status but deliberately does not advance last_read_at, and ListAllChatSessionsByCreator counted unread unconditionally. So an archived session that had unread replies kept reporting has_unread=true / unread_count>0 — a stuck badge the user can never clear (archived sessions are read-only and hidden from history, so there is no mark-as-read entry). MUL-4372 fixed only the quick-chat FAB surface; the sidebar Chat tab badge and the chat-window "other unread" header still counted it. Fix at the source: derive unread_count = 0 for status='archived' rows in ListAllChatSessionsByCreator. Because has_unread is server-derived as unread_count > 0, and all surfaces (FAB, sidebar via countUnreadChatMessages, chat-window header, and mobile) read this one payload, every badge drops archived sessions with no per-surface filter. last_read_at is left untouched so unarchiving restores the true unread state. Installed desktop clients benefit without an app update. Also zero unread optimistically in the archive mutation so no badge counts a just-archived session in the frame before the refetch lands (FAB already filtered archived; this keeps sidebar/header consistent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
127
packages/core/chat/mutations.test.tsx
Normal file
127
packages/core/chat/mutations.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { setApiInstance } from "../api";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { useSetChatSessionArchived } from "./mutations";
|
||||
import { chatKeys } from "./queries";
|
||||
import type { ChatSession } from "../types";
|
||||
|
||||
vi.mock("../hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper(qc: QueryClient) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSetChatSessionArchived", () => {
|
||||
let qc: QueryClient;
|
||||
let setChatSessionArchived: ReturnType<
|
||||
typeof vi.fn<(id: string, archived: boolean) => Promise<ChatSession>>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
setChatSessionArchived = vi.fn();
|
||||
setApiInstance({ setChatSessionArchived } as unknown as ApiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// MUL-4360: archiving must zero the row's unread locally so no badge (FAB,
|
||||
// sidebar Chat tab, chat-window header) keeps counting a just-archived
|
||||
// session in the frame before the refetch lands. Mirrors the backend, which
|
||||
// forces unread to 0 for archived rows in ListAllChatSessionsByCreator.
|
||||
it("optimistically zeroes unread when archiving", async () => {
|
||||
setChatSessionArchived.mockResolvedValue(
|
||||
makeSession({ status: "archived" }),
|
||||
);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [makeSession()]);
|
||||
|
||||
const { result } = renderHook(() => useSetChatSessionArchived(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ sessionId: "s1", archived: true });
|
||||
});
|
||||
|
||||
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 an unread count — the true state comes back
|
||||
// from the server refetch (last_read_at is untouched), so the optimistic
|
||||
// patch leaves the row's unread fields as-is.
|
||||
it("does not resurrect unread when unarchiving", async () => {
|
||||
setChatSessionArchived.mockResolvedValue(makeSession({ status: "active" }));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [
|
||||
makeSession({ status: "archived", has_unread: false, unread_count: 0 }),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useSetChatSessionArchived(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ sessionId: "s1", archived: false });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// On failure the optimistic patch (status + zeroed unread) rolls back whole.
|
||||
it("rolls back the unread patch when the request fails", async () => {
|
||||
setChatSessionArchived.mockRejectedValue(new Error("boom"));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [makeSession()]);
|
||||
|
||||
const { result } = renderHook(() => useSetChatSessionArchived(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.mutateAsync({ sessionId: "s1", archived: true }),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
const row = qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!;
|
||||
expect(row.status).toBe("active");
|
||||
expect(row.unread_count).toBe(2);
|
||||
expect(row.has_unread).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -199,6 +199,14 @@ export function useSetChatSessionPinned() {
|
||||
* `updated_at` so the row re-sorts by activity in whichever view it lands.
|
||||
* The matching `chat:session_updated` WS event carries the new status to other
|
||||
* tabs/devices — see use-realtime-sync.ts.
|
||||
*
|
||||
* Archiving also zeroes the row's unread locally so every badge (FAB, sidebar
|
||||
* Chat tab, chat-window header) drops it in the same frame the row moves to the
|
||||
* Archived view. The backend already forces unread to 0 for archived rows (see
|
||||
* ListAllChatSessionsByCreator / MUL-4360); this is the optimistic half so there
|
||||
* is no window where the header/sidebar still count a just-archived session
|
||||
* before the refetch lands. Unarchive does NOT restore a count here — the true
|
||||
* unread state comes back from the server refetch (last_read_at is untouched).
|
||||
*/
|
||||
export function useSetChatSessionArchived() {
|
||||
const qc = useQueryClient();
|
||||
@@ -220,7 +228,12 @@ export function useSetChatSessionArchived() {
|
||||
sortChatSessions(
|
||||
old.map((s) =>
|
||||
s.id === sessionId
|
||||
? { ...s, status: archived ? "archived" : "active", updated_at: nowIso }
|
||||
? {
|
||||
...s,
|
||||
status: archived ? "archived" : "active",
|
||||
updated_at: nowIso,
|
||||
...(archived ? { unread_count: 0, has_unread: false } : {}),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -788,3 +788,80 @@ VALUES ($1, $2, 'feishu', $3, 'p2p')
|
||||
t.Fatal("unarchive recreated the channel_chat_session_binding; it must not restore or steal the channel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListChatSessions_ArchivedSessionReportsZeroUnread pins the MUL-4360 fix:
|
||||
// ListAllChatSessionsByCreator forces unread_count/has_unread to 0 for archived
|
||||
// rows even when assistant messages sit past the read cursor, so a stuck unread
|
||||
// badge cannot survive on any surface (FAB, sidebar Chat tab, chat-window
|
||||
// header). Because archiving deliberately does NOT advance last_read_at,
|
||||
// unarchiving must restore the session's true unread count.
|
||||
func TestListChatSessions_ArchivedSessionReportsZeroUnread(t *testing.T) {
|
||||
agentID := createHandlerTestAgent(t, "ChatArchivedUnreadAgent", []byte("[]"))
|
||||
sessionID := createHandlerTestChatSession(t, agentID)
|
||||
ctx := context.Background()
|
||||
|
||||
// An assistant reply the user never read.
|
||||
if _, err := testPool.Exec(ctx,
|
||||
`INSERT INTO chat_message (chat_session_id, role, content) VALUES ($1, 'assistant', 'unread reply')`,
|
||||
sessionID); err != nil {
|
||||
t.Fatalf("insert assistant message: %v", err)
|
||||
}
|
||||
// Pin the read cursor firmly before that message so it counts as unread
|
||||
// regardless of insert-time clock skew (last_read_at defaults to now()).
|
||||
if _, err := testPool.Exec(ctx,
|
||||
`UPDATE chat_session SET last_read_at = 'epoch' WHERE id = $1`, sessionID); err != nil {
|
||||
t.Fatalf("reset last_read_at: %v", err)
|
||||
}
|
||||
|
||||
unreadOf := func() (int, bool) {
|
||||
t.Helper()
|
||||
req := newRequest("GET", "/api/chat/sessions?status=all", nil)
|
||||
req = withChatTestWorkspaceCtx(t, req)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.ListChatSessions(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListChatSessions: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []ChatSessionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
for _, s := range resp {
|
||||
if s.ID == sessionID {
|
||||
return s.UnreadCount, s.HasUnread
|
||||
}
|
||||
}
|
||||
t.Fatalf("session %s not found in list of %d", sessionID, len(resp))
|
||||
return 0, false
|
||||
}
|
||||
|
||||
archive := func(archived bool) {
|
||||
t.Helper()
|
||||
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID+"/archive", map[string]any{"archived": archived})
|
||||
req = withURLParam(req, "sessionId", sessionID)
|
||||
req = withChatTestWorkspaceCtx(t, req)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.SetChatSessionArchived(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SetChatSessionArchived(%v): expected 200, got %d: %s", archived, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Active baseline: the unread reply counts (guards against a regression that
|
||||
// would zero unread for live sessions too).
|
||||
if n, has := unreadOf(); n != 1 || !has {
|
||||
t.Fatalf("active session unread: want count=1 has=true, got count=%d has=%v", n, has)
|
||||
}
|
||||
|
||||
// Archived: unread forced to 0 even though the message still sits past the cursor.
|
||||
archive(true)
|
||||
if n, has := unreadOf(); n != 0 || has {
|
||||
t.Fatalf("archived session unread: want count=0 has=false, got count=%d has=%v", n, has)
|
||||
}
|
||||
|
||||
// Unarchive restores the true unread state (last_read_at was never touched).
|
||||
archive(false)
|
||||
if n, has := unreadOf(); n != 1 || !has {
|
||||
t.Fatalf("unarchived session unread: want count=1 has=true, got count=%d has=%v", n, has)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,10 +473,12 @@ func (q *Queries) LinkChatMessageToTask(ctx context.Context, arg LinkChatMessage
|
||||
|
||||
const listAllChatSessionsByCreator = `-- name: ListAllChatSessionsByCreator :many
|
||||
SELECT cs.id, cs.workspace_id, cs.agent_id, cs.creator_id, cs.title, cs.session_id, cs.work_dir, cs.status, cs.created_at, cs.updated_at, cs.unread_since, cs.runtime_id, cs.last_read_at, cs.is_agent_intro, cs.pinned_at,
|
||||
(SELECT count(*) FROM chat_message m
|
||||
WHERE m.chat_session_id = cs.id
|
||||
AND m.role = 'assistant'
|
||||
AND m.created_at > cs.last_read_at)::int AS unread_count,
|
||||
CASE WHEN cs.status = 'archived' THEN 0
|
||||
ELSE (SELECT count(*) FROM chat_message m
|
||||
WHERE m.chat_session_id = cs.id
|
||||
AND m.role = 'assistant'
|
||||
AND m.created_at > cs.last_read_at)
|
||||
END::int AS unread_count,
|
||||
COALESCE(lm.content, '') AS last_message_content,
|
||||
COALESCE(lm.role, '') AS last_message_role,
|
||||
lm.created_at AS last_message_at,
|
||||
@@ -523,6 +525,13 @@ type ListAllChatSessionsByCreatorRow struct {
|
||||
LastMessageKind string `json:"last_message_kind"`
|
||||
}
|
||||
|
||||
// Unlike ListChatSessionsByCreator this returns archived sessions too (for the
|
||||
// "Archived" view), so unread must be forced to 0 for archived rows: archiving
|
||||
// deliberately does NOT advance last_read_at (so unarchive can restore the true
|
||||
// unread state), but an archived session is read-only and hidden from history,
|
||||
// so any residual unread is uncleanable and must not light up any badge. Gating
|
||||
// on status here is the single source of truth for all unread surfaces (FAB,
|
||||
// sidebar Chat tab, chat-window header) — see MUL-4360.
|
||||
func (q *Queries) ListAllChatSessionsByCreator(ctx context.Context, arg ListAllChatSessionsByCreatorParams) ([]ListAllChatSessionsByCreatorRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAllChatSessionsByCreator, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
|
||||
@@ -37,11 +37,20 @@ WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
|
||||
ORDER BY (cs.pinned_at IS NOT NULL) DESC, cs.pinned_at DESC, COALESCE(lm.created_at, cs.updated_at) DESC;
|
||||
|
||||
-- name: ListAllChatSessionsByCreator :many
|
||||
-- Unlike ListChatSessionsByCreator this returns archived sessions too (for the
|
||||
-- "Archived" view), so unread must be forced to 0 for archived rows: archiving
|
||||
-- deliberately does NOT advance last_read_at (so unarchive can restore the true
|
||||
-- unread state), but an archived session is read-only and hidden from history,
|
||||
-- so any residual unread is uncleanable and must not light up any badge. Gating
|
||||
-- on status here is the single source of truth for all unread surfaces (FAB,
|
||||
-- sidebar Chat tab, chat-window header) — see MUL-4360.
|
||||
SELECT cs.*,
|
||||
(SELECT count(*) FROM chat_message m
|
||||
WHERE m.chat_session_id = cs.id
|
||||
AND m.role = 'assistant'
|
||||
AND m.created_at > cs.last_read_at)::int AS unread_count,
|
||||
CASE WHEN cs.status = 'archived' THEN 0
|
||||
ELSE (SELECT count(*) FROM chat_message m
|
||||
WHERE m.chat_session_id = cs.id
|
||||
AND m.role = 'assistant'
|
||||
AND m.created_at > cs.last_read_at)
|
||||
END::int AS unread_count,
|
||||
COALESCE(lm.content, '') AS last_message_content,
|
||||
COALESCE(lm.role, '') AS last_message_role,
|
||||
lm.created_at AS last_message_at,
|
||||
|
||||
Reference in New Issue
Block a user