mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* fix(chat): use a single New Chat draft, keyed by session not agent (MUL-4864)
An uncreated chat kept one draft per agent (`__new__:<agentId>`), so
switching the agent picker mid-compose swapped the draft out from under
the user and left invisible per-agent drafts behind. That slot shape was
never a product decision: it fell out of 34e452776, which keyed the
editor by agent to remount it and refresh the Tiptap placeholder. The
placeholder now syncs live, so the motive is gone — and mobile already
implements the target contract.
An uncreated chat now has ONE draft slot per workspace. `selectedAgentId`
is the send target, not draft ownership. Created sessions keep their own
per-session slots, so conversations stay isolated.
Three connected changes this needs to actually hold:
- Editor identity no longer tracks the agent. Keying it by agent kept the
draft slot correct but still remounted on switch, dropping whatever the
100ms draft debounce had not yet persisted — the last thing typed.
- The post-send "scrub the composer?" rule reads `activeSessionId` alone.
Moving the picker is no longer navigation, so counting it as such left
a completed send's text in the composer, primed to be sent a second
time to the agent just picked. Both send chains (the chat tab's
controller and the floating ChatWindow) now share one exported rule
rather than two copies of the predicate.
- Legacy `__new__:<agentId>` slots are folded on load. They carry no
timestamp, so when several exist none can be shown to be newest: adopt
the persisted `selectedAgentId`'s slot (the draft the workspace would
have opened with) and drop the rest — those extras are the invisible
multi-draft state this removes. Text and attachments migrate together
so a draft can't end up with another agent's files.
Tests cover the acceptance scenarios: agent switch preserves text,
attachments and the live editor instance; sessions stay isolated; the
first send clears the draft and routes to the agent selected at send
time; a failed create keeps the input; and the migration adopts, prunes,
persists, and is idempotent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): file unflushed keystrokes under the draft they were typed in (MUL-4864)
Review blocker: one editor instance serves every chat draft and its
onUpdate is debounced 100ms. Typing in session A and switching to B
inside that window fires the timer with B's draftKey in scope — because
onUpdate always resolves to the latest render's closure — writing A's
document into B's draft. Worse, ContentEditor's dirty guard suppresses
B's incoming sync while those unflushed bytes are live, so B's own draft
never loads and A's context could be sent to B's agent.
This predates the per-agent draft change: on main `editorKey` is
`selectedAgentId`, so any two sessions of the SAME agent already shared
one editor instance and already cross-wrote. Verified by running the new
regression against main's editor identity with the flush removed — it
fails there too. Unifying the New Chat draft widened the same hazard to
cross-agent switches, so it is fixed here rather than left latent.
Fix at the root: a debounced write must land on the key it was typed
under, not wherever the composer now points.
- ContentEditor gains `flushPendingUpdate()`: cancels the armed debounce
and hands its markdown back rather than firing it, and advances the
emit watermark so the editor reads clean and the dirty guard stops
blocking the incoming sync. Distinct from flushPendingOnUnmount — the
instance is alive, so it reads the live document.
- ChatInput flushes on draftKey change and commits the result to the
PREVIOUS key. useLayoutEffect, not useEffect: passive effects run
child-first, so a passive flush would land after ContentEditor's sync
had already skipped on a dirty editor; a layout effect is part of the
commit, so no pending timer can fire ahead of it.
- onUpdate and the flush now share one `commitDraft`, so text and
attachment pruning cannot diverge between the two paths.
Tests exercise the REAL debounce and REAL dirty guard, not an instant
mock onUpdate:
- chat-input-draft-isolation.test.tsx (new): mounts the real
ContentEditor with only Tiptap's primitive mocked. Pins A/B isolation
across a mid-debounce switch, that B's draft actually loads, that a
lazy session create keeps its bytes in the New Chat slot, and that an
agent switch still preserves the New Chat draft. Three of four fail
without the flush.
- content-editor.test.tsx: flushPendingUpdate hands back the pending
markdown, kills the timer, unblocks the next sync, and leaves the
guard intact when NOT flushed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): keep an in-flight upload bound to the draft it started in (MUL-4864)
Second review blocker. An upload PINS the editor to its source document:
Guard 0 refuses to setContent over an `uploading` node, because wiping it
strands the upload's finalize and the file silently vanishes. So while an
upload runs, the instance still holds A's document even though the user
has navigated to B — and the upload's own completion dispatch fires
onUpdate on that instance, which resolves to the latest render's closure.
Result: A's body and its attachment URL were written into B's draft, B's
own draft was destroyed, and A lost the upload it was waiting for.
Nothing stopped this: useUploadGate gates submit, not session/agent
navigation. Reproduced as a test before fixing — B's "B's own words" was
replaced by A's body + A's CDN URL.
Root cause is the same shape as the debounce bug, one level up: the
editor's writes were keyed off what is SELECTED, when they belong to what
is LOADED. Those are the same key except while an upload pins the
document.
- chat-input models that explicitly with `editorDraftKeyRef` — the draft
whose document the instance holds. Every editor-driven write (onUpdate,
the upload's attachment binding) uses it, not `draftKey`.
- The draft switch defers while `hasActiveUploads()`, leaving both the
document and its writes on the source key, then completes when the gate
clears. Blocking navigation instead would make the user wait on a
network round-trip to change tabs.
- The deferred case must force the adopt: ContentEditor's sync effect
CONSUMED the defaultValue change while Guard 0 was up
(lastDefaultValueRef advances before the guard), so it never re-runs and
B's draft would never load. New `adoptContent()` ref method lands it;
its body is the sync effect's own apply path, extracted so both agree.
- handleSend refuses while loaded !== selected. The upload gate covers
most of that window but not the sliver between the upload's final
dispatch and the adopt re-render — React flushes that on a scheduler
task, so Mod+Enter can land first.
Tests (real ContentEditor, real Guard 0, real debounce, real upload
lifecycle incl. the transaction that publishes the queue flip):
completing upload's markdown stays in the source draft and never reaches
the target; an attachment dropped while pinned binds to the source; the
target draft loads once the guard clears; send is refused mid-divergence.
Each verified to fail without its fix — the send guard initially passed
vacuously (the disabled SubmitButton swallowed the click) and now drives
the real Mod+Enter path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
578 lines
22 KiB
TypeScript
578 lines
22 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { act, renderHook } from "@testing-library/react";
|
|
import type { Agent, ChatSession } 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,
|
|
setActiveSession: vi.fn((id: string | null) => {
|
|
store.activeSessionId = id;
|
|
}),
|
|
setSelectedAgentId: vi.fn((id: string | null) => {
|
|
store.selectedAgentId = 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(),
|
|
removeFromCaches: vi.fn(),
|
|
// useQuery reads these so each test can vary the loaded data.
|
|
sessions: [] as ChatSession[],
|
|
agents: [] as Agent[],
|
|
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/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 }),
|
|
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("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.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 });
|
|
});
|
|
});
|
|
|
|
// 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"] }),
|
|
);
|
|
});
|
|
});
|