mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* fix(chat): collapse chat-done flicker via inline cache write
The chat panel flickered at end-of-turn: live TimelineView unmounted →
short blank + scroll jump → persistent AssistantMessage finally appeared.
Root cause: chat:done's WS handler called setQueryData(pendingTask, {})
synchronously while invalidateQueries(messages) was an async refetch.
The render guard pendingAlreadyPersisted (chat-message-list.tsx:62-68)
expected the persisted message to already be in the messages cache
before pending cleared, but the sync/async ordering broke that guard.
Fix follows TkDodo's "combine setQueryData (active query) + invalidate
(others)" pattern. ChatDonePayload now carries the freshly-persisted
ChatMessage (id, content, elapsed_ms, created_at); the WS handler
writes it into chatKeys.messages BEFORE clearing pending. Same render
tick → AssistantMessage mounts before TimelineView unmounts → no
flicker. invalidate(messages) stays as a fallback for clients that
took the older code path or for content drift (redaction, etc.).
Also slim task:completed's chat branch — chat:done already wrote the
message and cleared pending; task:completed only refreshes the
cross-session pending aggregate that drives the FAB.
Field additions are all `omitempty` / TS `?:` so older clients ignore
them and older servers (no fields populated) fall back to invalidate-
only, preserving prior behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): cover chat done cache handoff
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
118 lines
3.3 KiB
TypeScript
118 lines
3.3 KiB
TypeScript
import { QueryClient } from "@tanstack/react-query";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { chatKeys } from "../chat/queries";
|
|
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
|
|
import { applyChatDoneToCache } from "./use-realtime-sync";
|
|
|
|
const sessionId = "session-1";
|
|
const taskId = "task-1";
|
|
const messagesKey = chatKeys.messages(sessionId);
|
|
const pendingKey = chatKeys.pendingTask(sessionId);
|
|
|
|
function createQueryClient() {
|
|
return new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
},
|
|
});
|
|
}
|
|
|
|
function userMessage(): ChatMessage {
|
|
return {
|
|
id: "msg-user",
|
|
chat_session_id: sessionId,
|
|
role: "user",
|
|
content: "hello",
|
|
task_id: null,
|
|
created_at: "2026-05-13T05:00:00Z",
|
|
};
|
|
}
|
|
|
|
function donePayload(overrides: Partial<ChatDonePayload> = {}): ChatDonePayload {
|
|
return {
|
|
chat_session_id: sessionId,
|
|
task_id: taskId,
|
|
message_id: "msg-assistant",
|
|
content: "done",
|
|
elapsed_ms: 1234,
|
|
created_at: "2026-05-13T05:00:02Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("applyChatDoneToCache", () => {
|
|
it("writes the assistant message before clearing pending task", () => {
|
|
const qc = createQueryClient();
|
|
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
|
|
qc.setQueryData<ChatPendingTask>(pendingKey, {
|
|
task_id: taskId,
|
|
status: "running",
|
|
});
|
|
|
|
const setQueryData = vi.spyOn(qc, "setQueryData");
|
|
|
|
applyChatDoneToCache(qc, donePayload());
|
|
|
|
expect(setQueryData.mock.calls[0]?.[0]).toEqual(messagesKey);
|
|
expect(setQueryData.mock.calls[1]?.[0]).toEqual(pendingKey);
|
|
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
|
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
|
|
userMessage(),
|
|
{
|
|
id: "msg-assistant",
|
|
chat_session_id: sessionId,
|
|
role: "assistant",
|
|
content: "done",
|
|
task_id: taskId,
|
|
created_at: "2026-05-13T05:00:02Z",
|
|
elapsed_ms: 1234,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not duplicate a replayed chat done event", () => {
|
|
const qc = createQueryClient();
|
|
const assistant: ChatMessage = {
|
|
id: "msg-assistant",
|
|
chat_session_id: sessionId,
|
|
role: "assistant",
|
|
content: "done",
|
|
task_id: taskId,
|
|
created_at: "2026-05-13T05:00:02Z",
|
|
elapsed_ms: 1234,
|
|
};
|
|
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage(), assistant]);
|
|
qc.setQueryData<ChatPendingTask>(pendingKey, {
|
|
task_id: taskId,
|
|
status: "running",
|
|
});
|
|
|
|
applyChatDoneToCache(qc, donePayload());
|
|
|
|
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
|
|
userMessage(),
|
|
assistant,
|
|
]);
|
|
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
|
});
|
|
|
|
it("falls back to invalidation-only when older servers omit message fields", () => {
|
|
const qc = createQueryClient();
|
|
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
|
|
qc.setQueryData<ChatPendingTask>(pendingKey, {
|
|
task_id: taskId,
|
|
status: "running",
|
|
});
|
|
|
|
applyChatDoneToCache(
|
|
qc,
|
|
donePayload({ message_id: undefined, content: undefined }),
|
|
);
|
|
|
|
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
|
|
userMessage(),
|
|
]);
|
|
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
|
});
|
|
});
|