mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* 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>
196 lines
7.4 KiB
TypeScript
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 });
|
|
});
|
|
});
|