mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
807 lines
30 KiB
TypeScript
807 lines
30 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { act, renderHook } from "@testing-library/react";
|
|
import type { Agent, ChatSession, Project } from "@multica/core/types";
|
|
|
|
interface QueuedRestore {
|
|
id: string;
|
|
content: string;
|
|
attachments?: unknown[];
|
|
sessionId: string;
|
|
}
|
|
|
|
// --- 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,
|
|
selectedProjectId: null as string | null,
|
|
setActiveSession: vi.fn((id: string | null) => {
|
|
store.activeSessionId = id;
|
|
}),
|
|
setSelectedAgentId: vi.fn((id: string | null) => {
|
|
store.selectedAgentId = id;
|
|
}),
|
|
setSelectedProjectId: vi.fn((id: string | null) => {
|
|
store.selectedProjectId = id;
|
|
}),
|
|
appliedDraftRestoreIds: [] as string[],
|
|
markDraftRestoreApplied: vi.fn((id: string) => {
|
|
if (!store.appliedDraftRestoreIds.includes(id)) {
|
|
store.appliedDraftRestoreIds = [...store.appliedDraftRestoreIds, id];
|
|
}
|
|
}),
|
|
forgetDraftRestoreApplied: vi.fn((id: string) => {
|
|
store.appliedDraftRestoreIds = store.appliedDraftRestoreIds.filter((x) => x !== id);
|
|
}),
|
|
// Server-less restores (a failed send, a synchronous cancel) queue per session
|
|
// and are persisted; the shared slot never holds one for another session.
|
|
pendingSendRestores: {} as Record<string, QueuedRestore[]>,
|
|
enqueuePendingSendRestore: vi.fn((r: QueuedRestore) => {
|
|
const existing = store.pendingSendRestores[r.sessionId] ?? [];
|
|
if (existing.some((q) => q.id === r.id)) return;
|
|
store.pendingSendRestores = {
|
|
...store.pendingSendRestores,
|
|
[r.sessionId]: [...existing, r],
|
|
};
|
|
}),
|
|
dequeuePendingSendRestore: vi.fn((sessionId: string, restoreId: string) => {
|
|
const existing = store.pendingSendRestores[sessionId] ?? [];
|
|
const remaining = existing.filter((q) => q.id !== restoreId);
|
|
const next = { ...store.pendingSendRestores };
|
|
if (remaining.length > 0) next[sessionId] = remaining;
|
|
else delete next[sessionId];
|
|
store.pendingSendRestores = next;
|
|
}),
|
|
};
|
|
return {
|
|
store,
|
|
archivedMutate: vi.fn(),
|
|
markReadMutate: vi.fn(),
|
|
// Stable across renders so tests can assert on it; lazy-creates the session
|
|
// a new chat's first send needs.
|
|
createSessionMutate: vi.fn(async () => ({ id: "new-session" })),
|
|
// Foreground gate for the auto mark-read effect; tests flip it.
|
|
appForeground: { value: true },
|
|
consumeRestoreMutate: vi.fn(),
|
|
setProjectMutate: vi.fn(),
|
|
removeFromCaches: vi.fn(),
|
|
// useQuery reads these so each test can vary the loaded data.
|
|
sessions: [] as ChatSession[],
|
|
agents: [] as Agent[],
|
|
projects: [] as Project[],
|
|
draftRestores: null as
|
|
| { restores: { id: string; chat_session_id: string; content: string }[] }
|
|
| null,
|
|
};
|
|
});
|
|
|
|
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/core/projects/queries", () => ({
|
|
projectListOptions: () => ({ queryKey: ["projects"] }),
|
|
}));
|
|
vi.mock("@multica/views/issues/components", () => ({ canAssignAgent: () => true }));
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: { sendChatMessage: vi.fn(), cancelTaskById: vi.fn() },
|
|
// Names the 403 that a revoked invoke permission raises (MUL-4525); plain
|
|
// failures have no reason code.
|
|
dispatchReasonCode: () => undefined,
|
|
}));
|
|
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: h.createSessionMutate }),
|
|
useMarkChatSessionRead: () => ({ mutate: h.markReadMutate }),
|
|
useSetChatSessionArchived: () => ({ mutate: h.archivedMutate }),
|
|
useSetChatSessionProject: () => ({
|
|
mutate: h.setProjectMutate,
|
|
isPending: false,
|
|
}),
|
|
useConsumeChatDraftRestore: () => ({ mutate: h.consumeRestoreMutate }),
|
|
}));
|
|
vi.mock("../../common/use-app-foreground", () => ({
|
|
useAppForeground: () => h.appForeground.value,
|
|
}));
|
|
vi.mock("@multica/core/chat", () => ({
|
|
useChatStore: Object.assign(
|
|
(sel: (s: typeof h.store) => unknown) => sel(h.store),
|
|
{ getState: () => h.store },
|
|
),
|
|
}));
|
|
vi.mock("@multica/core/realtime", () => ({
|
|
removeChatMessageFromCaches: h.removeFromCaches,
|
|
}));
|
|
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 };
|
|
if (key.includes("projects")) return { data: h.projects, isSuccess: true };
|
|
if (key.includes("draft-restores")) return { data: h.draftRestores };
|
|
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";
|
|
import { api } from "@multica/core/api";
|
|
|
|
// --- 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.store.selectedProjectId = null;
|
|
h.sessions = sessions;
|
|
h.agents = agents;
|
|
h.projects = [];
|
|
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.store.setSelectedProjectId.mockClear();
|
|
h.archivedMutate.mockClear();
|
|
return result;
|
|
}
|
|
|
|
describe("useChatController project context", () => {
|
|
const project = {
|
|
id: "project-a",
|
|
workspace_id: "ws-1",
|
|
title: "Project Alpha",
|
|
} as Project;
|
|
const otherProject = {
|
|
id: "project-b",
|
|
workspace_id: "ws-1",
|
|
title: "Project Beta",
|
|
} as Project;
|
|
|
|
beforeEach(() => {
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
h.createSessionMutate.mockClear();
|
|
h.setProjectMutate.mockClear();
|
|
h.createSessionMutate.mockResolvedValue({ id: "new-session" });
|
|
});
|
|
|
|
afterEach(() => {
|
|
h.store.selectedProjectId = null;
|
|
h.projects = [];
|
|
});
|
|
|
|
it("removes the project from the current chat without leaving it", () => {
|
|
const projectSession = makeSession({
|
|
id: "project-session",
|
|
agent_id: "agent-a",
|
|
project_id: project.id,
|
|
});
|
|
h.store.activeSessionId = projectSession.id;
|
|
h.store.selectedProjectId = project.id;
|
|
h.sessions = [projectSession];
|
|
h.agents = [agentA];
|
|
h.projects = [project];
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
|
|
act(() => result.current.handleProjectChange(null));
|
|
|
|
expect(h.setProjectMutate).toHaveBeenCalledWith({
|
|
sessionId: projectSession.id,
|
|
projectId: null,
|
|
});
|
|
expect(h.store.setSelectedProjectId).not.toHaveBeenCalled();
|
|
expect(h.store.setActiveSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("starts a fresh chat when switching an existing chat to another project", () => {
|
|
const projectSession = makeSession({
|
|
id: "project-session",
|
|
agent_id: "agent-a",
|
|
project_id: project.id,
|
|
});
|
|
h.store.activeSessionId = projectSession.id;
|
|
h.store.selectedProjectId = project.id;
|
|
h.sessions = [projectSession];
|
|
h.agents = [agentA];
|
|
h.projects = [project, otherProject];
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
|
|
act(() => result.current.handleProjectChange(otherProject.id));
|
|
|
|
expect(h.setProjectMutate).not.toHaveBeenCalled();
|
|
expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(otherProject.id);
|
|
expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("pins the fresh chat to the open session's agent when selectedAgentId is stale", () => {
|
|
// The open session belongs to agent B, but the persisted preference is
|
|
// still agent A. Switching to another project clears the active session,
|
|
// which would otherwise drop selection back to the stale agent A and send
|
|
// the lazily-created chat to the wrong agent (MUL-5150 regression). The
|
|
// switch must first sync selectedAgentId to the open session's agent.
|
|
const projectSession = makeSession({
|
|
id: "project-session",
|
|
agent_id: agentB.id,
|
|
project_id: project.id,
|
|
});
|
|
h.store.activeSessionId = projectSession.id;
|
|
h.store.selectedAgentId = agentA.id; // stale preference for a different agent
|
|
h.store.selectedProjectId = project.id;
|
|
h.sessions = [projectSession];
|
|
h.agents = [agentA, agentB];
|
|
h.projects = [project, otherProject];
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedAgentId.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
|
|
act(() => result.current.handleProjectChange(otherProject.id));
|
|
|
|
expect(h.store.setSelectedAgentId).toHaveBeenCalledWith(agentB.id);
|
|
expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(otherProject.id);
|
|
expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("does not write project changes into the new-chat draft while an active session resolves", () => {
|
|
h.store.activeSessionId = "loading-session";
|
|
h.store.selectedProjectId = project.id;
|
|
h.sessions = [];
|
|
h.agents = [agentA];
|
|
h.projects = [project];
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
|
|
act(() => result.current.handleProjectChange(null));
|
|
|
|
expect(h.setProjectMutate).not.toHaveBeenCalled();
|
|
expect(h.store.setSelectedProjectId).not.toHaveBeenCalled();
|
|
expect(h.store.setActiveSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists the selected project when lazy-creating a session", async () => {
|
|
h.store.activeSessionId = null;
|
|
h.store.selectedAgentId = agentA.id;
|
|
h.store.selectedProjectId = project.id;
|
|
h.sessions = [];
|
|
h.agents = [agentA];
|
|
h.projects = [project];
|
|
vi.mocked(api.sendChatMessage).mockResolvedValue({
|
|
message_id: "message-1",
|
|
task_id: "task-1",
|
|
created_at: new Date(0).toISOString(),
|
|
} as Awaited<ReturnType<typeof api.sendChatMessage>>);
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
await act(async () => {
|
|
await result.current.handleSend("hello", undefined, vi.fn());
|
|
});
|
|
|
|
expect(h.createSessionMutate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ project_id: project.id }),
|
|
);
|
|
});
|
|
|
|
it("does not inherit the current session project when starting a new chat", () => {
|
|
const projectSession = makeSession({
|
|
id: "project-session",
|
|
agent_id: "agent-a",
|
|
project_id: project.id,
|
|
});
|
|
h.store.activeSessionId = projectSession.id;
|
|
h.store.selectedProjectId = project.id;
|
|
h.sessions = [projectSession];
|
|
h.agents = [agentA, agentB];
|
|
h.projects = [project];
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
|
|
act(() => result.current.handleStartNewChat(agentB));
|
|
|
|
expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(null);
|
|
expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("clears the current session project when using the plain new-chat action", () => {
|
|
const projectSession = makeSession({
|
|
id: "project-session",
|
|
agent_id: "agent-a",
|
|
project_id: project.id,
|
|
});
|
|
h.store.activeSessionId = projectSession.id;
|
|
h.store.selectedProjectId = project.id;
|
|
h.sessions = [projectSession];
|
|
h.agents = [agentA];
|
|
h.projects = [project];
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
|
|
act(() => result.current.handleNewChat());
|
|
|
|
expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(null);
|
|
expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it("keeps a historical session project out of the next-chat draft state", () => {
|
|
const projectSession = makeSession({
|
|
id: "project-session",
|
|
agent_id: "agent-a",
|
|
project_id: project.id,
|
|
});
|
|
h.store.activeSessionId = null;
|
|
h.store.selectedProjectId = null;
|
|
h.sessions = [projectSession];
|
|
h.agents = [agentA];
|
|
h.projects = [project];
|
|
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
h.store.setSelectedProjectId.mockClear();
|
|
|
|
act(() => result.current.handleSelectSession(projectSession));
|
|
|
|
expect(h.store.setSelectedProjectId).not.toHaveBeenCalled();
|
|
expect(h.store.setActiveSession).toHaveBeenCalledWith(projectSession.id);
|
|
});
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|
|
|
|
// MUL-4360 mount race: `activeSessionId` is persisted, so on a bare `/chat`
|
|
// navigation the page restores the last session as active for one frame before
|
|
// its URL→store effect clears it back to null. The auto-mark-read must NOT fire
|
|
// for that transiently-active session — otherwise the badge vanishes though the
|
|
// user never opened it (the exact "no active session yet the red dot cleared"
|
|
// report). The read is deferred a tick and cancelled when activeSessionId moves.
|
|
describe("useChatController auto mark-read", () => {
|
|
const unread = makeSession({
|
|
id: "sA",
|
|
agent_id: "agent-a",
|
|
has_unread: true,
|
|
unread_count: 2,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
h.store.activeSessionId = null;
|
|
h.store.selectedAgentId = null;
|
|
h.markReadMutate.mockClear();
|
|
h.appForeground.value = true;
|
|
h.sessions = [unread];
|
|
h.agents = [agentA];
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("marks read a session that stays active past the tick", () => {
|
|
h.store.activeSessionId = "sA";
|
|
renderHook(() => useChatController());
|
|
|
|
// Deferred, not synchronous — nothing fires on the mount frame.
|
|
expect(h.markReadMutate).not.toHaveBeenCalled();
|
|
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(h.markReadMutate).toHaveBeenCalledWith("sA");
|
|
});
|
|
|
|
it("does NOT mark read a session that was only momentarily active on mount", () => {
|
|
// Mount restores sA as active (persisted, unread)...
|
|
h.store.activeSessionId = "sA";
|
|
const { rerender } = renderHook(() => useChatController());
|
|
|
|
// ...then the page's URL→store effect clears it before the tick elapses.
|
|
h.store.activeSessionId = null;
|
|
rerender();
|
|
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(h.markReadMutate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// Foreground gating (MUL-4485): a reply that lands while the app is backgrounded
|
|
// must stay unread and clear once the user returns. This composes with the
|
|
// MUL-4360 mount-race defer above — the read is scheduled a tick after the
|
|
// effect runs — so each assertion advances fake timers to let it (or not) fire.
|
|
describe("useChatController auto mark-read — foreground gating (MUL-4485)", () => {
|
|
const unreadActive = makeSession({ id: "sU", agent_id: "agent-a", has_unread: true });
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
h.markReadMutate.mockClear();
|
|
h.appForeground.value = true;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function renderController(activeSessionId: string | null, sessions: ChatSession[]) {
|
|
h.store.activeSessionId = activeSessionId;
|
|
h.store.selectedAgentId = null;
|
|
h.sessions = sessions;
|
|
h.agents = [agentA];
|
|
return renderHook(() => useChatController({ isActive: true }));
|
|
}
|
|
|
|
it("marks the active unread session read while the app is in the foreground", () => {
|
|
renderController("sU", [unreadActive]);
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(h.markReadMutate).toHaveBeenCalledWith("sU");
|
|
});
|
|
|
|
it("does NOT mark read while the app is backgrounded, so the reply stays unread", () => {
|
|
h.appForeground.value = false;
|
|
renderController("sU", [unreadActive]);
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(h.markReadMutate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("marks read once the user returns to the foreground", () => {
|
|
h.appForeground.value = false;
|
|
const { rerender } = renderController("sU", [unreadActive]);
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(h.markReadMutate).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
h.appForeground.value = true;
|
|
rerender();
|
|
});
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(h.markReadMutate).toHaveBeenCalledWith("sU");
|
|
});
|
|
});
|
|
|
|
describe("useChatController durable draft restores (#5219)", () => {
|
|
beforeEach(() => {
|
|
h.draftRestores = null;
|
|
h.consumeRestoreMutate.mockClear();
|
|
h.removeFromCaches.mockClear();
|
|
h.store.appliedDraftRestoreIds = [];
|
|
h.store.markDraftRestoreApplied.mockClear();
|
|
h.store.forgetDraftRestoreApplied.mockClear();
|
|
h.store.pendingSendRestores = {};
|
|
h.store.enqueuePendingSendRestore.mockClear();
|
|
h.store.dequeuePendingSendRestore.mockClear();
|
|
h.appForeground.value = true;
|
|
});
|
|
|
|
// The reconnect/offline recovery path: no chat:cancel_finalized event was
|
|
// seen — the restore arrives purely through the draft-restores query on
|
|
// composer mount, is handed to the composer, and is consumed server-side
|
|
// only after the composer reports the hand-off complete.
|
|
it("hands a fetched restore to the composer and consumes it after the hand-off", () => {
|
|
h.draftRestores = {
|
|
restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
|
|
};
|
|
const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
|
|
|
|
expect(result.current.restoreDraftRequest).toEqual({
|
|
id: "msg-1",
|
|
content: "run the thing",
|
|
attachments: undefined,
|
|
sessionId: "sA",
|
|
serverRestoreId: "msg-1",
|
|
});
|
|
// The deleted bubble may still be cached when the event was missed.
|
|
expect(h.removeFromCaches).toHaveBeenCalledWith(expect.anything(), "sA", "msg-1");
|
|
// Not yet consumed: the draft isn't persisted client-side until the
|
|
// composer takes it.
|
|
expect(h.consumeRestoreMutate).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
result.current.handleRestoreDraftApplied();
|
|
});
|
|
|
|
// The ledger entry is written BEFORE the request goes out: a consume that
|
|
// never lands must not let this restore be offered (and re-sent) again.
|
|
expect(h.store.markDraftRestoreApplied).toHaveBeenCalledWith("msg-1");
|
|
expect(h.consumeRestoreMutate).toHaveBeenCalledWith(
|
|
{ sessionId: "sA", restoreId: "msg-1" },
|
|
expect.anything(),
|
|
);
|
|
expect(result.current.restoreDraftRequest).toBeNull();
|
|
});
|
|
|
|
// The lost-consume case (#5219 review): the DELETE never reached the server,
|
|
// so the row comes back on the next fetch — but it was already applied, and
|
|
// the user may have sent it. It must be reconciled (consumed again), never
|
|
// re-offered to the composer.
|
|
it("reconciles a row whose consume was lost instead of re-offering it", () => {
|
|
h.store.appliedDraftRestoreIds = ["msg-1"];
|
|
h.draftRestores = {
|
|
restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
|
|
};
|
|
const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
|
|
|
|
expect(result.current.restoreDraftRequest).toBeNull();
|
|
expect(h.consumeRestoreMutate).toHaveBeenCalledWith(
|
|
{ sessionId: "sA", restoreId: "msg-1" },
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("leaves a restore belonging to another session pending", () => {
|
|
h.draftRestores = {
|
|
restores: [{ id: "msg-2", chat_session_id: "sB", content: "other session draft" }],
|
|
};
|
|
const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
|
|
|
|
expect(result.current.restoreDraftRequest).toBeNull();
|
|
expect(h.consumeRestoreMutate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// A backgrounded browser tab still renders this controller. It must not claim a
|
|
// restore the user is waiting on in a foreground surface — the same silent-theft
|
|
// hazard the hidden chat window guards against, reached here through the app
|
|
// foreground gate rather than isOpen.
|
|
it("does NOT offer or consume a restore while the app is backgrounded", () => {
|
|
h.appForeground.value = false;
|
|
h.draftRestores = {
|
|
restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
|
|
};
|
|
const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
|
|
|
|
expect(result.current.restoreDraftRequest).toBeNull();
|
|
expect(h.consumeRestoreMutate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("offers the restore once the surface returns to the foreground", () => {
|
|
h.appForeground.value = false;
|
|
h.draftRestores = {
|
|
restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
|
|
};
|
|
h.store.activeSessionId = "sA";
|
|
h.store.selectedAgentId = null;
|
|
h.sessions = [sA, sB, sC];
|
|
h.agents = [agentA, agentB];
|
|
const { result, rerender } = renderHook(() => useChatController());
|
|
|
|
expect(result.current.restoreDraftRequest).toBeNull();
|
|
|
|
act(() => {
|
|
h.appForeground.value = true;
|
|
rerender();
|
|
});
|
|
|
|
expect(result.current.restoreDraftRequest?.serverRestoreId).toBe("msg-1");
|
|
});
|
|
});
|
|
|
|
// After a send, the composer is scrubbed only if the user is still on the
|
|
// session they sent from — otherwise the shared editor is showing a different
|
|
// draft and clearing it would wipe visible input. MUL-4864 changes what
|
|
// "still here" means: with ONE new-chat draft, the composer no longer belongs
|
|
// to an agent, so only `activeSessionId` can answer it.
|
|
describe("useChatController.handleSend — compose target tracking", () => {
|
|
beforeEach(() => {
|
|
h.store.setActiveSession.mockClear();
|
|
h.createSessionMutate.mockClear();
|
|
h.createSessionMutate.mockResolvedValue({ id: "new-session" });
|
|
vi.mocked(api.sendChatMessage).mockResolvedValue({
|
|
message_id: "msg-1",
|
|
task_id: "task-1",
|
|
created_at: new Date(0).toISOString(),
|
|
} as unknown as Awaited<ReturnType<typeof api.sendChatMessage>>);
|
|
});
|
|
|
|
function sendFrom(activeSessionId: string | null, whileSending?: () => void) {
|
|
h.store.activeSessionId = activeSessionId;
|
|
h.store.selectedAgentId = "agent-a";
|
|
h.sessions = [sA];
|
|
h.agents = [agentA];
|
|
const { result } = renderHook(() => useChatController());
|
|
h.store.setActiveSession.mockClear();
|
|
const commitInput = vi.fn();
|
|
return {
|
|
commitInput,
|
|
send: () =>
|
|
act(async () => {
|
|
const pending = result.current.handleSend("hello", undefined, commitInput);
|
|
// Runs between ensureSession and the commit — the window the user
|
|
// actually races with when they touch the picker after hitting send.
|
|
whileSending?.();
|
|
await pending;
|
|
}),
|
|
};
|
|
}
|
|
|
|
it("scrubs the composer after a new chat's first send", async () => {
|
|
const { commitInput, send } = sendFrom(null);
|
|
await send();
|
|
|
|
expect(commitInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({ clearEditor: true, extraDraftKeys: ["new-session"] }),
|
|
);
|
|
expect(h.store.setActiveSession).toHaveBeenCalledWith("new-session");
|
|
});
|
|
|
|
it("scrubs the composer even if the agent picker moved mid-send", async () => {
|
|
// The sent text is still sitting in the one shared composer. Leaving it
|
|
// there would arm a second, unintended send to the agent just picked.
|
|
const { commitInput, send } = sendFrom(null, () => {
|
|
h.store.selectedAgentId = "agent-b";
|
|
});
|
|
await send();
|
|
|
|
expect(commitInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({ clearEditor: true, extraDraftKeys: ["new-session"] }),
|
|
);
|
|
// The message goes to the agent selected when Send was pressed — a later
|
|
// flick of the picker does not re-route work already on its way.
|
|
expect(h.createSessionMutate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ agent_id: "agent-a" }),
|
|
);
|
|
// …and that session is what opens, so the user sees the reply they asked for.
|
|
expect(h.store.setActiveSession).toHaveBeenCalledWith("new-session");
|
|
});
|
|
|
|
it("keeps the input when session create fails, and opens nothing", async () => {
|
|
h.createSessionMutate.mockRejectedValue(new Error("create failed"));
|
|
const { commitInput, send } = sendFrom(null);
|
|
await send();
|
|
|
|
// No commit = the draft is never cleared, so the user's words survive.
|
|
expect(commitInput).not.toHaveBeenCalled();
|
|
expect(h.store.setActiveSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves the composer alone when the user navigated to another session mid-send", async () => {
|
|
// Genuine navigation: the editor now shows sB's draft, which this send has
|
|
// no business clearing. Fire-and-forget — the reply surfaces as unread.
|
|
const { commitInput, send } = sendFrom(null, () => {
|
|
h.store.activeSessionId = "sB";
|
|
});
|
|
await send();
|
|
|
|
expect(commitInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({ clearEditor: false, extraDraftKeys: ["new-session"] }),
|
|
);
|
|
expect(h.store.setActiveSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still clears the sent draft when sending from an existing session", async () => {
|
|
const { commitInput, send } = sendFrom("sA");
|
|
await send();
|
|
|
|
expect(commitInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({ clearEditor: true, extraDraftKeys: ["sA"] }),
|
|
);
|
|
});
|
|
});
|