diff --git a/packages/core/chat/mutations.test.tsx b/packages/core/chat/mutations.test.tsx new file mode 100644 index 0000000000..088250665b --- /dev/null +++ b/packages/core/chat/mutations.test.tsx @@ -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 { + 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 {children}; + }; +} + +describe("useSetChatSessionArchived", () => { + let qc: QueryClient; + let setChatSessionArchived: ReturnType< + typeof vi.fn<(id: string, archived: boolean) => Promise> + >; + + 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(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(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(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(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(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(chatKeys.sessions(WS_ID))![0]!; + expect(row.status).toBe("active"); + expect(row.unread_count).toBe(2); + expect(row.has_unread).toBe(true); + }); +}); diff --git a/packages/core/chat/mutations.ts b/packages/core/chat/mutations.ts index 3c70442491..cdb8f9a5e6 100644 --- a/packages/core/chat/mutations.ts +++ b/packages/core/chat/mutations.ts @@ -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, ), ); diff --git a/server/internal/handler/chat_test.go b/server/internal/handler/chat_test.go index 63c3edf9c5..877fc2e343 100644 --- a/server/internal/handler/chat_test.go +++ b/server/internal/handler/chat_test.go @@ -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) + } +} diff --git a/server/pkg/db/generated/chat.sql.go b/server/pkg/db/generated/chat.sql.go index 95b68d871e..249b1edc41 100644 --- a/server/pkg/db/generated/chat.sql.go +++ b/server/pkg/db/generated/chat.sql.go @@ -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 { diff --git a/server/pkg/db/queries/chat.sql b/server/pkg/db/queries/chat.sql index 63f97b6a82..56f2600c28 100644 --- a/server/pkg/db/queries/chat.sql +++ b/server/pkg/db/queries/chat.sql @@ -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,