Files
multica/packages/core/chat/mutations.test.tsx
Jiayuan Zhang a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

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

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
2026-07-24 11:30:27 +08:00

260 lines
8.3 KiB
TypeScript

/**
* @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 {
useConsumeChatDraftRestore,
useSetChatSessionArchived,
useSetChatSessionProject,
} 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);
});
});
describe("useSetChatSessionProject", () => {
let qc: QueryClient;
let updateChatSession: ReturnType<
typeof vi.fn<
(
id: string,
data: { title: string } | { project_id: string | null },
) => Promise<ChatSession>
>
>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
updateChatSession = vi.fn();
setApiInstance({ updateChatSession } as unknown as ApiClient);
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("optimistically removes the project from the existing session", async () => {
updateChatSession.mockResolvedValue(makeSession({ project_id: null }));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [
makeSession({ project_id: "project-1" }),
]);
const { result } = renderHook(() => useSetChatSessionProject(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({ sessionId: "s1", projectId: null });
});
expect(updateChatSession).toHaveBeenCalledWith("s1", { project_id: null });
expect(qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!.project_id).toBeNull();
});
it("rolls the project back when the update fails", async () => {
updateChatSession.mockRejectedValue(new Error("boom"));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [
makeSession({ project_id: "project-1" }),
]);
const { result } = renderHook(() => useSetChatSessionProject(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await expect(
result.current.mutateAsync({ sessionId: "s1", projectId: null }),
).rejects.toThrow("boom");
});
expect(
qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!.project_id,
).toBe("project-1");
});
});
// The consume DELETE is the last step of a durable draft restore (#5219), and
// mutations are `retry: false` app-wide — a single dropped request would leave
// the row behind, to be re-offered later. The endpoint is idempotent, so this
// one retries instead of giving up on the first failure.
describe("useConsumeChatDraftRestore", () => {
let qc: QueryClient;
beforeEach(() => {
vi.useFakeTimers();
qc = new QueryClient();
});
afterEach(() => {
vi.useRealTimers();
qc.clear();
vi.restoreAllMocks();
});
it("retries a lost consume until the server acknowledges it", async () => {
const consumeChatDraftRestore = vi
.fn<(sessionId: string, restoreId: string) => Promise<void>>()
.mockRejectedValueOnce(new Error("network"))
.mockResolvedValueOnce(undefined);
setApiInstance({ consumeChatDraftRestore } as unknown as ApiClient);
const onSuccess = vi.fn();
const { result } = renderHook(() => useConsumeChatDraftRestore(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ sessionId: "s1", restoreId: "r1" }, { onSuccess });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(consumeChatDraftRestore).toHaveBeenCalledTimes(2);
expect(onSuccess).toHaveBeenCalledTimes(1);
});
it("drops the row from the cache immediately so it is not re-offered", async () => {
const consumeChatDraftRestore = vi.fn().mockResolvedValue(undefined);
setApiInstance({ consumeChatDraftRestore } as unknown as ApiClient);
qc.setQueryData(chatKeys.draftRestores("s1"), {
restores: [
{ id: "r1", chat_session_id: "s1", content: "keep me" },
{ id: "r2", chat_session_id: "s1", content: "other" },
],
});
const { result } = renderHook(() => useConsumeChatDraftRestore(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ sessionId: "s1", restoreId: "r1" });
});
const cached = qc.getQueryData(chatKeys.draftRestores("s1")) as {
restores: { id: string }[];
};
expect(cached.restores.map((r) => r.id)).toEqual(["r2"]);
});
});