Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/chat/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
59 changes: 59 additions & 0 deletions packages/core/chat/store.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
31 changes: 31 additions & 0 deletions packages/core/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions packages/views/chat/components/chat-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions packages/views/chat/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 22 additions & 2 deletions packages/views/chat/components/chat-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
32 changes: 31 additions & 1 deletion packages/views/editor/content-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
},
};
Expand All @@ -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;
Expand Down Expand Up @@ -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" />);
Expand Down
15 changes: 15 additions & 0 deletions packages/views/editor/content-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading