Compare commits

...

3 Commits

Author SHA1 Message Date
Naiyuan Qing
076ec4c867 fix(chat): migrate new-chat draft onto the session id on lazy create
The first file upload in a brand-new chat lazily creates the session, flipping
ChatInput's draft key from `__new__:agent` to the session id mid-upload. The
in-progress (empty-href) file-card markdown the editor had already written into
the `__new__:agent` draft was neither migrated nor cleared, so it stayed
stranded under that key — and resurfaced as a stale `!file[name]()` the next
time a new chat opened for the same agent (the send only cleared the
session-keyed draft).

Migrate the `__new__:agent` draft onto the new session id the moment the
session is created (upload path only — text send already clears the pre-flip
key via `keyAtSend`). Add a shared `newSessionDraftKey` helper so ChatInput and
ensureSession agree on the slot name, and a `migrateInputDraft` store action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:50:07 +08:00
Naiyuan Qing
1356cf1d82 test(editor): cover the in-flight-upload content-sync guard
The content-sync effect now reads `editor.state.doc.descendants` on every run
to detect uploading nodes; the mocked editor didn't implement it, crashing all
ContentEditor tests. Add `descendants` (driven by `editorState.uploadingNodes`)
to the mock and a regression test asserting an external `defaultValue` change
does not setContent while an upload is in flight, and resumes once it settles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:40:37 +08:00
Naiyuan Qing
c0af331c03 fix(editor): don't wipe in-flight uploads on external content sync
When a brand-new chat's first file upload triggers lazy session creation,
`setActiveSession(null → uuid)` flips ChatInput's draft key mid-upload, which
changes `defaultValue` to the new (empty) session draft. ContentEditor's
"sync external defaultValue" effect then ran `setContent` over a document that
still held the `uploading` image/fileCard node, wiping it — so the upload's
finalize could no longer find the node. The file vanished and the draft was
left with an empty `!file[name]()`.

The editor was never remounted (instance stays alive); the node was removed by
the content-sync effect. An uploading node is local state an external sync must
not overwrite, exactly like the existing dirty/focused guards. Add a guard that
bails the sync while any `uploading` node is present.

Pure frontend; affects only the first upload in a new chat (subsequent uploads
hit an existing session, so no draft-key flip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:31:36 +08:00
8 changed files with 162 additions and 7 deletions

View File

@@ -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";

View 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");
});
});

View File

@@ -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));

View File

@@ -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,

View File

@@ -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);

View File

@@ -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(

View File

@@ -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" />);

View File

@@ -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,