Files
multica/packages/views/chat/components/use-chat-controller.test.tsx
Jiayuan Zhang 4a93f4ce41 fix(chat): advance selection to next chat when archiving the open session (#5110)
* fix(chat): advance selection to next chat when archiving the open session

Archiving the chat currently open in the two-pane Chat tab left the
conversation pane showing a now read-only, dangling session. Mirror the
Inbox list's handleArchive: move selection to the next chat in the
sorted, non-archived history list, fall back to the previous one, and
clear only when nothing is left. Archiving a non-open row is unchanged.

Closes MUL-4278

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): route archive-advance through the shared controller

Address review of #5110:

- Advance now routes through handleSelectSession so selectedAgentId stays
  in sync when the next chat belongs to a different agent (a follow-up
  "new chat" no longer defaults to the archived chat's agent).
- Move the advance/next-prev/clear logic into use-chat-controller
  (advanceSelectionAfterArchive + archiveSession) and drive both Chat-tab
  entry points from a single ChatPage.handleArchive: the thread-list row
  AND the conversation header ⋯ menu (the header previously only flipped
  status, stranding the user on the archived read-only conversation).
- Mobile: archiving the open fullscreen conversation returns to the list.
- Floating window: archiving the open chat now advances to the next chat
  (with cross-agent sync) instead of clearing, matching the Chat tab.

Tests: controller test covers advance/fallback/clear/no-op + cross-agent
sync; thread-list test now asserts it delegates to onArchive.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 23:13:46 +08:00

196 lines
7.4 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { Agent, ChatSession } from "@multica/core/types";
// --- Shared mutable state (hoisted so vi.mock factories can reach it) --------
const h = vi.hoisted(() => {
const store = {
activeSessionId: null as string | null,
selectedAgentId: null as string | null,
setActiveSession: vi.fn((id: string | null) => {
store.activeSessionId = id;
}),
setSelectedAgentId: vi.fn((id: string | null) => {
store.selectedAgentId = id;
}),
};
return {
store,
archivedMutate: vi.fn(),
// useQuery reads these so each test can vary the loaded data.
sessions: [] as ChatSession[],
agents: [] as Agent[],
};
});
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-1" }));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (sel: (s: { user: { id: string } }) => unknown) =>
sel({ user: { id: "user-1" } }),
}));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: () => ({ queryKey: ["agents"] }),
memberListOptions: () => ({ queryKey: ["members"] }),
}));
vi.mock("@multica/views/issues/components", () => ({ canAssignAgent: () => true }));
vi.mock("@multica/core/api", () => ({
api: { sendChatMessage: vi.fn(), cancelTaskById: vi.fn() },
}));
vi.mock("@multica/core/agents", () => ({
useAgentPresenceDetail: () => ({ availability: "online" }),
useWorkspaceAgentAvailability: () => "available",
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
vi.mock("@multica/core/chat/mutations", () => ({
useCreateChatSession: () => ({ mutateAsync: vi.fn() }),
useMarkChatSessionRead: () => ({ mutate: vi.fn() }),
useSetChatSessionArchived: () => ({ mutate: h.archivedMutate }),
}));
vi.mock("@multica/core/chat", () => ({
useChatStore: Object.assign(
(sel: (s: typeof h.store) => unknown) => sel(h.store),
{ getState: () => h.store },
),
}));
vi.mock("@multica/core/logger", () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}));
vi.mock("../../i18n", () => ({ useT: () => ({ t: () => "x" }) }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQuery: (options: { queryKey?: unknown[] }) => {
const key = options.queryKey ?? [];
if (key.includes("agents")) return { data: h.agents };
if (key.includes("members")) {
return { data: [{ user_id: "user-1", role: "admin" }] };
}
if (key.includes("sessions")) return { data: h.sessions, isSuccess: true };
return { data: null };
},
useInfiniteQuery: () => ({
data: undefined,
isLoading: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
}),
useQueryClient: () => ({
getQueryData: vi.fn(),
setQueryData: vi.fn(),
invalidateQueries: vi.fn(),
}),
};
});
import { useChatController } from "./use-chat-controller";
// --- Fixtures ---------------------------------------------------------------
function makeSession(
overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agent_id">,
): ChatSession {
return {
workspace_id: "ws-1",
creator_id: "user-1",
title: `Chat ${overrides.id}`,
status: "active",
has_unread: false,
unread_count: 0,
last_message: null,
pinned: false,
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
...overrides,
};
}
const agentA = { id: "agent-a", name: "Alpha" } as unknown as Agent;
const agentB = { id: "agent-b", name: "Beta" } as unknown as Agent;
// Descending updated_at → sortChatSessions renders them sA, sB, sC.
const sA = makeSession({ id: "sA", agent_id: "agent-a", updated_at: "2026-07-08T03:00:00Z" });
const sB = makeSession({ id: "sB", agent_id: "agent-b", updated_at: "2026-07-08T02:00:00Z" });
const sC = makeSession({ id: "sC", agent_id: "agent-a", updated_at: "2026-07-08T01:00:00Z" });
function setup(activeSessionId: string | null, sessions: ChatSession[], agents: Agent[]) {
h.store.activeSessionId = activeSessionId;
h.store.selectedAgentId = null;
h.sessions = sessions;
h.agents = agents;
const { result } = renderHook(() => useChatController());
// Ignore any render-time store writes (self-heal etc.); we assert only the
// effect of the call under test.
h.store.setActiveSession.mockClear();
h.store.setSelectedAgentId.mockClear();
h.archivedMutate.mockClear();
return result;
}
describe("useChatController.advanceSelectionAfterArchive", () => {
beforeEach(() => {
h.store.setActiveSession.mockClear();
h.store.setSelectedAgentId.mockClear();
h.archivedMutate.mockClear();
});
it("advances to the next chat and syncs the selected agent across agents", () => {
const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
act(() => result.current.advanceSelectionAfterArchive(sA));
expect(h.store.setActiveSession).toHaveBeenCalledWith("sB");
// The next chat belongs to a different agent — selectedAgentId must follow
// so a subsequent "new chat" defaults to the right agent (the review bug).
expect(h.store.setSelectedAgentId).toHaveBeenCalledWith("agent-b");
});
it("does not touch the selected agent when the next chat is the same agent", () => {
// Both chats belong to agent-a; archiving the open one advances within the
// same agent, so there is no reason to rewrite selectedAgentId.
const a1 = makeSession({ id: "a1", agent_id: "agent-a", updated_at: "2026-07-08T03:00:00Z" });
const a2 = makeSession({ id: "a2", agent_id: "agent-a", updated_at: "2026-07-08T02:00:00Z" });
const result = setup("a1", [a1, a2], [agentA, agentB]);
act(() => result.current.advanceSelectionAfterArchive(a1));
expect(h.store.setActiveSession).toHaveBeenCalledWith("a2");
expect(h.store.setSelectedAgentId).not.toHaveBeenCalled();
});
it("falls back to the previous chat when archiving the last open one", () => {
const result = setup("sC", [sA, sB, sC], [agentA, agentB]);
act(() => result.current.advanceSelectionAfterArchive(sC));
expect(h.store.setActiveSession).toHaveBeenCalledWith("sB");
});
it("clears the selection when archiving the only chat", () => {
const only = makeSession({ id: "only", agent_id: "agent-a" });
const result = setup("only", [only], [agentA]);
act(() => result.current.advanceSelectionAfterArchive(only));
expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
expect(h.store.setSelectedAgentId).not.toHaveBeenCalled();
});
it("is a no-op when the archived chat is not the open one", () => {
const result = setup("sB", [sA, sB, sC], [agentA, agentB]);
act(() => result.current.advanceSelectionAfterArchive(sA));
expect(h.store.setActiveSession).not.toHaveBeenCalled();
expect(h.store.setSelectedAgentId).not.toHaveBeenCalled();
});
});
describe("useChatController.archiveSession", () => {
it("fires the archive mutation for the given session", () => {
const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
act(() => result.current.archiveSession("sA"));
expect(h.archivedMutate).toHaveBeenCalledWith({ sessionId: "sA", archived: true });
});
});