mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
3 Commits
agent/lamb
...
fix/chat-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076ec4c867 | ||
|
|
1356cf1d82 | ||
|
|
c0af331c03 |
@@ -1,4 +1,4 @@
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION, newSessionDraftKey } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
export { useRecentContextStore, selectRecentContexts } from "./recent-context-store";
|
||||
export type { RecentContextEntry, RecentContextType } from "./recent-context-store";
|
||||
|
||||
59
packages/core/chat/store.test.ts
Normal file
59
packages/core/chat/store.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createChatStore, newSessionDraftKey } from "./store";
|
||||
import type { StorageAdapter } from "../types";
|
||||
|
||||
function memStorage(): StorageAdapter {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => m.get(k) ?? null,
|
||||
setItem: (k, v) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("newSessionDraftKey", () => {
|
||||
it("derives a stable per-agent slot for an uncreated chat", () => {
|
||||
expect(newSessionDraftKey("agent-1")).toBe("__new__:agent-1");
|
||||
expect(newSessionDraftKey(null)).toBe("__new__:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat store — migrateInputDraft", () => {
|
||||
let store: ReturnType<typeof createChatStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createChatStore({ storage: memStorage() });
|
||||
});
|
||||
|
||||
it("moves a draft to the new key and clears the source", () => {
|
||||
const from = newSessionDraftKey("agent-1");
|
||||
store.getState().setInputDraft(from, "!file[x.pdf]()");
|
||||
|
||||
store.getState().migrateInputDraft(from, "session-1");
|
||||
|
||||
const drafts = store.getState().inputDrafts;
|
||||
expect(drafts["session-1"]).toBe("!file[x.pdf]()");
|
||||
// Source slot is cleared so it can't resurface in the next new chat.
|
||||
expect(from in drafts).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op when the source draft is absent", () => {
|
||||
store.getState().setInputDraft("session-1", "keep me");
|
||||
|
||||
store.getState().migrateInputDraft(newSessionDraftKey("agent-1"), "session-1");
|
||||
|
||||
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
|
||||
});
|
||||
|
||||
it("is a no-op when from === to", () => {
|
||||
store.getState().setInputDraft("session-1", "keep me");
|
||||
|
||||
store.getState().migrateInputDraft("session-1", "session-1");
|
||||
|
||||
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,16 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
|
||||
/**
|
||||
* Draft storage key for an as-yet-uncreated chat with the given agent.
|
||||
* Shared by ChatInput (which writes the draft) and ensureSession (which
|
||||
* migrates it onto the real session id the moment the session is created),
|
||||
* so the two never disagree on the slot name.
|
||||
*/
|
||||
export function newSessionDraftKey(selectedAgentId: string | null): string {
|
||||
return `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
|
||||
}
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
@@ -84,6 +94,14 @@ export interface ChatState {
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
/**
|
||||
* Move a draft from one key to another, deleting the source. Used when a
|
||||
* chat session is lazily created: the `__new__:agent` draft is migrated
|
||||
* onto the real sessionId so it isn't stranded under the abandoned key
|
||||
* (which would resurface as a stale draft the next time a new chat opens
|
||||
* for that agent).
|
||||
*/
|
||||
migrateInputDraft: (from: string, to: string) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
@@ -159,6 +177,19 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
migrateInputDraft: (from, to) => {
|
||||
if (from === to) return;
|
||||
const current = get().inputDrafts;
|
||||
if (!(from in current)) {
|
||||
logger.debug("migrateInputDraft skipped (no source draft)", { from, to });
|
||||
return;
|
||||
}
|
||||
logger.info("migrateInputDraft", { from, to });
|
||||
const next = { ...current, [to]: current[from]! };
|
||||
delete next[from];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
|
||||
@@ -121,6 +121,7 @@ vi.mock("@multica/core/chat", () => {
|
||||
};
|
||||
return {
|
||||
DRAFT_NEW_SESSION: "__draft_new__",
|
||||
newSessionDraftKey: (agentId: string | null) => `__draft_new__:${agentId ?? ""}`,
|
||||
useChatStore: Object.assign(
|
||||
(selector?: (s: typeof state) => unknown) =>
|
||||
selector ? selector(state) : state,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
|
||||
import { useChatStore, newSessionDraftKey } from "@multica/core/chat";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
@@ -85,8 +85,7 @@ export function ChatInput({
|
||||
// user would see the image flash on then disappear. Keeping editor
|
||||
// identity stable across the lazy-create event is what makes
|
||||
// first-upload-creates-session work the same as second-upload.
|
||||
const draftKey =
|
||||
activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
|
||||
const draftKey = activeSessionId ?? newSessionDraftKey(selectedAgentId);
|
||||
// Select a primitive — empty-string fallback keeps referential stability.
|
||||
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
|
||||
const setInputDraft = useChatStore((s) => s.setInputDraft);
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
useMarkChatSessionRead,
|
||||
useUpdateChatSession,
|
||||
} from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { useChatStore, newSessionDraftKey } from "@multica/core/chat";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
@@ -186,6 +186,7 @@ export function ChatWindow() {
|
||||
const setOpen = useChatStore((s) => s.setOpen);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
||||
const migrateInputDraft = useChatStore((s) => s.migrateInputDraft);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
@@ -370,8 +371,19 @@ export function ChatWindow() {
|
||||
|
||||
const handleUploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
// An upload in a brand-new chat lazily creates the session, flipping the
|
||||
// draft key from `__new__:agent` to the session id mid-upload. The
|
||||
// in-progress (empty-href) file-card markdown the editor already wrote
|
||||
// into the `__new__:agent` draft would otherwise be stranded there and
|
||||
// resurface as a stale `!file[name]()` the next time a new chat opens for
|
||||
// this agent. Migrate that draft onto the session id so it travels with
|
||||
// the session and the `__new__:agent` slot is cleared.
|
||||
const wasNewSession = !activeSessionId;
|
||||
const sessionId = await ensureSession("");
|
||||
if (!sessionId) return null;
|
||||
if (wasNewSession) {
|
||||
migrateInputDraft(newSessionDraftKey(selectedAgentId), sessionId);
|
||||
}
|
||||
// Prime the messages cache as empty before flipping activeSessionId so
|
||||
// ChatMessageList mounts directly (no Skeleton frame). Skip the write
|
||||
// when an entry already exists — a concurrent handleSend may have
|
||||
@@ -384,7 +396,15 @@ export function ChatWindow() {
|
||||
setActiveSession(sessionId);
|
||||
return uploadWithToast(file, { chatSessionId: sessionId });
|
||||
},
|
||||
[ensureSession, uploadWithToast, qc, setActiveSession],
|
||||
[
|
||||
activeSessionId,
|
||||
ensureSession,
|
||||
migrateInputDraft,
|
||||
selectedAgentId,
|
||||
uploadWithToast,
|
||||
qc,
|
||||
setActiveSession,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelChatTask = useCallback(
|
||||
|
||||
@@ -10,6 +10,9 @@ const editorState = vi.hoisted(() => ({
|
||||
isFocused: false,
|
||||
isDestroyed: false,
|
||||
markdown: "",
|
||||
// Nodes the mocked doc reports via `descendants`. The content-sync effect
|
||||
// walks these to detect in-flight uploads; default empty = nothing uploading.
|
||||
uploadingNodes: [] as Array<{ attrs: { uploading?: boolean } }>,
|
||||
}));
|
||||
|
||||
// Records the attachments[] prop the provider received on its most recent
|
||||
@@ -83,7 +86,14 @@ vi.mock("@tiptap/react", () => ({
|
||||
},
|
||||
getMarkdown: () => editorState.markdown,
|
||||
state: {
|
||||
doc: { content: { size: 0 } },
|
||||
doc: {
|
||||
content: { size: 0 },
|
||||
descendants: (cb: (node: { attrs: { uploading?: boolean } }) => boolean | void) => {
|
||||
for (const node of editorState.uploadingNodes) {
|
||||
if (cb(node) === false) break;
|
||||
}
|
||||
},
|
||||
},
|
||||
selection: { empty: true, from: 0, to: 0 },
|
||||
},
|
||||
};
|
||||
@@ -109,6 +119,7 @@ describe("ContentEditor", () => {
|
||||
editorState.isFocused = false;
|
||||
editorState.isDestroyed = false;
|
||||
editorState.markdown = "";
|
||||
editorState.uploadingNodes = [];
|
||||
editorRef.current = null;
|
||||
onCreateFired.value = false;
|
||||
latestEditorOptions.current = undefined;
|
||||
@@ -155,6 +166,25 @@ describe("ContentEditor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not sync while a file upload is in flight (in-flight upload node must survive external defaultValue changes)", () => {
|
||||
editorState.markdown = "old content";
|
||||
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
||||
|
||||
// A file is uploading: the doc holds a node with attrs.uploading. An
|
||||
// external defaultValue change (e.g. chat lazy-creating a session mid-upload
|
||||
// flips the draft key → defaultValue) must NOT setContent over it, or the
|
||||
// uploading node is wiped and the upload's finalize can't find it.
|
||||
editorState.uploadingNodes = [{ attrs: { uploading: true } }];
|
||||
rerender(<ContentEditor defaultValue="" />);
|
||||
|
||||
expect(mockSetContent).not.toHaveBeenCalled();
|
||||
|
||||
// Once the upload settles (no uploading node), a later external change syncs.
|
||||
editorState.uploadingNodes = [];
|
||||
rerender(<ContentEditor defaultValue="new content from server" />);
|
||||
expect(mockSetContent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not sync when editor is focused and has unsaved local edits", () => {
|
||||
editorState.markdown = "old content";
|
||||
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
||||
|
||||
@@ -384,6 +384,21 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
|
||||
// Guard 0: never clobber an in-flight upload. An external `defaultValue`
|
||||
// change can arrive mid-upload — e.g. chat lazy-creates a session on the
|
||||
// first file upload, which flips `activeSessionId` → the draft key →
|
||||
// `defaultValue`. If we `setContent` over a document that still holds an
|
||||
// `uploading` image/fileCard node, that node is wiped and the upload's
|
||||
// finalize can no longer find it (the file vanishes, leaving an empty
|
||||
// `!file[name]()`). Like the dirty guards below, an uploading node is
|
||||
// local state that an external sync must not overwrite.
|
||||
let hasUploadingNode = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.attrs.uploading) hasUploadingNode = true;
|
||||
return !hasUploadingNode;
|
||||
});
|
||||
if (hasUploadingNode) return;
|
||||
|
||||
const current = stripBlobUrls(editor.getMarkdown()).trimEnd();
|
||||
// "Dirty" = user has local edits not yet flushed through the debounced
|
||||
// `onUpdate`. `lastEmittedRef` is advanced only after a debounce fire,
|
||||
|
||||
Reference in New Issue
Block a user