Files
multica/packages/views/editor/content-editor.tsx
Naiyuan Qing f46b929ebc fix(editor): don't wipe in-flight uploads on external content sync (#4196)
* 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>

* 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>

* 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>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:57:17 +08:00

524 lines
22 KiB
TypeScript

"use client";
/**
* ContentEditor — the rich-text editor used wherever the user TYPES content.
*
* Architecture decisions (April 2026 refactor):
*
* 1. EDITING ONLY. Read-only display is handled by `ReadonlyContent` (a
* react-markdown renderer), not this component. There used to be an
* `editable` prop here that toggled between modes, but every readonly
* callsite migrated to ReadonlyContent and the prop only invited
* misuse — Tiptap's `useEditor` reads `editable` at mount, so toggling
* the prop later silently failed (mounted-as-readonly editors stayed
* unfocusable forever). To express "currently disabled", wrap this
* component in a layout that sets `pointer-events-none` / `aria-disabled`
* — don't reach into the editor.
*
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
* Previously we had a custom `markdownToHtml()` pipeline (Marked library)
* for loading and regex post-processing for saving — two asymmetric paths
* that caused roundtrip inconsistencies. The @tiptap/markdown extension
* (v3.21.0+) handles table cell <p> wrapping and custom mention tokenizers
* natively, eliminating the need for the HTML detour.
*
* 3. PREPROCESSING is minimal: only legacy mention shortcode migration and
* URL linkification (preprocessMarkdown). No HTML conversion.
*
* Tech: Tiptap v3.22.1 (ProseMirror wrapper), @tiptap/markdown for
* bidirectional Markdown ↔ ProseMirror JSON conversion.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@multica/ui/lib/utils";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { useWorkspaceSlug } from "@multica/core/paths";
import { useQueryClient } from "@tanstack/react-query";
import type { Attachment } from "@multica/core/types";
import {
parseMarkdownChunked,
MARKDOWN_CHUNK_THRESHOLD,
type MarkdownManagerLike,
} from "./utils/parse-markdown-chunked";
import type { MentionItem } from "./extensions/mention-suggestion";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import { openLink, isMentionHref } from "./utils/link-handler";
import { EditorBubbleMenu } from "./bubble-menu";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { AttachmentDownloadProvider } from "./attachment-download-context";
import "katex/dist/katex.min.css";
import "./styles/index.css";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Blob URLs (blob:http://…) are process-local and expire on reload. Strip them
* from serialised markdown so they never reach the database. */
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
function stripBlobUrls(md: string): string {
return md.replace(BLOB_IMAGE_RE, "");
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ContentEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onBlur?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
/** Show the floating formatting toolbar on text selection. Defaults true. */
showBubbleMenu?: boolean;
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
submitOnEnter?: boolean;
/**
* ID of the issue this editor belongs to. When set, the bubble menu exposes
* a "Create sub-issue from selection" action that parents the new issue
* under this ID and replaces the selection with a mention link.
*/
currentIssueId?: string;
/**
* When true, the `@` suggestion picker is disabled but the mention node
* type remains in the schema, so existing mentions pasted in from other
* Multica editors still render as the normal pill. Use for editors where
* *creating* a new mention has no business meaning (e.g. agent system
* prompts) but *preserving* an existing one still matters.
*/
disableMentions?: boolean;
/** Chat can surface current/recent issue/project suggestions. Other editors use default mention behavior. */
mentionMode?: "default" | "context";
mentionContextItems?: MentionItem[];
/** Enable the `/` command picker. Defaults false. */
enableSlashCommands?: boolean;
/**
* Which `/` menu to show when enableSlashCommands is true: "skill" (default)
* lists the active agent's skills (chat); "command" shows the fixed built-in
* command menu (issue comments), e.g. /note.
*/
slashCommandMode?: "skill" | "command";
/**
* Attachments referenced by this content. The download buttons on file
* cards and images inside the editor look up an attachment by `url` and
* fetch a fresh CloudFront signature at click time, so a stale URL
* persisted in markdown never opens. Pass `issue.attachments` /
* `comment.attachments` etc.; omit when no attachment context is
* available (NodeView buttons fall back to opening the raw URL).
*/
attachments?: Attachment[];
/**
* Flush a pending debounced `onUpdate` when the editor unmounts instead of
* dropping it. Default false ON PURPOSE: most composers clear their draft
* and then unmount (comment edit cancel, create-issue / feedback submit),
* and a flush there would hand the discarded content right back to
* `onUpdate`, resurrecting the cleared draft. Opt in only where closing
* means "keep what the user last saw" — e.g. the issue-detail description
* editor, whose 1500ms debounce would otherwise drop a paste made just
* before the modal closes.
*/
flushPendingOnUnmount?: boolean;
}
interface ContentEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
/** Drop focus from the editor — used by chat after send so the caret
* stops competing with the StatusPill / streaming reply for the user's
* attention. */
blur: () => void;
uploadFile: (file: File) => void;
/** True when file uploads are still in progress. */
hasActiveUploads: () => boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
function ContentEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
className,
debounceMs = 300,
onSubmit,
onBlur,
onUploadFile,
showBubbleMenu = true,
submitOnEnter = false,
currentIssueId,
disableMentions = false,
mentionMode = "default",
mentionContextItems,
enableSlashCommands = false,
slashCommandMode = "skill",
attachments,
flushPendingOnUnmount = false,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const flushPendingOnUnmountRef = useRef(flushPendingOnUnmount);
// Markdown serialized at `onUpdate` time, awaiting its debounce fire. The
// unmount flush emits this cached copy — it runs mid-teardown and can't
// assume the editor instance is still readable.
const pendingFlushRef = useRef<string | null>(null);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef<
((file: File) => Promise<UploadResult | null>) | undefined
>(undefined);
const mentionContextItemsRef = useRef<MentionItem[]>(mentionContextItems ?? []);
const lastEmittedRef = useRef<string | null>(null);
// In-session record of attachments freshly uploaded through this editor.
// Surfaces (like the quick-create modal) that don't have a server-supplied
// `attachments` prop still need the AttachmentDownloadProvider to know
// about images the user just pasted/dropped — without a record in scope,
// Attachment.normalize() can't swap the persisted /api/attachments/<id>/
// download URL to a freshly-loadable one, and the <img> renders broken in
// any environment where the renderer's origin doesn't proxy /api to the
// API host (MUL-3192, Desktop/Electron).
const [sessionUploads, setSessionUploads] = useState<Attachment[]>([]);
// Wrap the caller-supplied uploader so we can stash each successful result
// in `sessionUploads`. The wrapper is rebuilt only when the underlying
// `onUploadFile` identity changes, so the inner ref handed to Tiptap stays
// stable across renders the way the original passthrough did.
const wrappedOnUploadFile = useMemo(() => {
if (!onUploadFile) return undefined;
return async (file: File): Promise<UploadResult | null> => {
const result = await onUploadFile(file);
// Only track attachments that carry a persisted id — the no-workspace
// avatar branch returns an id-less record that the resolver can't key
// off of, and tracking it would just bloat memory without helping
// anyone. See useFileUpload's `markdownLink` docstring for why.
if (result?.id) {
setSessionUploads((prev) =>
// Deduplicate on id so a re-upload (or a paste-then-drop of the
// same blob) doesn't create a parallel record.
prev.some((a) => a.id === result.id) ? prev : [...prev, result],
);
}
return result;
};
}, [onUploadFile]);
// Merged list fed to AttachmentDownloadProvider. Caller-supplied attachments
// (issue / comment editors that pre-load the full attachments[] from the
// server) take precedence — we only append session uploads the caller
// doesn't already have, so a parent re-render that includes the same record
// doesn't end up with two copies.
//
// One exception on id collision: when the caller's copy has an EMPTY
// `download_url` (the create-issue draft strips the short-lived signed URL
// before persisting), backfill it from the session upload. The session copy
// holds the this-response signed URL, so the just-pasted image first-paints
// from it instead of taking an extra redirect hop through `markdown_url`.
const providerAttachments = useMemo(() => {
if (sessionUploads.length === 0) return attachments;
const sessionById = new Map(sessionUploads.map((a) => [a.id, a]));
const merged: Attachment[] = [];
for (const a of attachments ?? []) {
const session = a.id ? sessionById.get(a.id) : undefined;
if (session) sessionById.delete(a.id);
merged.push(
session && !a.download_url
? { ...a, download_url: session.download_url }
: a,
);
}
merged.push(...sessionById.values());
return merged;
}, [attachments, sessionUploads]);
// Current workspace slug kept in a ref so the click handler always sees the
// latest value without recreating the editor. Used by openLink to prefix
// legacy /issues/... style paths that lack a workspace slug.
const workspaceSlug = useWorkspaceSlug();
const workspaceSlugRef = useRef(workspaceSlug);
workspaceSlugRef.current = workspaceSlug;
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = wrappedOnUploadFile;
mentionContextItemsRef.current = mentionContextItems ?? [];
flushPendingOnUnmountRef.current = flushPendingOnUnmount;
const queryClient = useQueryClient();
const initialContent = defaultValue ? preprocessMarkdown(defaultValue) : "";
// Large markdown is parsed in chunks to dodge marked's O(n²) tokenizer (see
// parseMarkdownChunked). Small docs stay on the single-parse fast path.
const mountChunked = initialContent.length > MARKDOWN_CHUNK_THRESHOLD;
const editor = useEditor({
immediatelyRender: false,
// Note: in v3.22.1 the default is already false/undefined (same behavior).
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
shouldRerenderOnTransaction: false,
onCreate: ({ editor: ed }) => {
// For large docs we mount empty (below) and parse in chunks here, so the
// O(n²) marked tokenizer never sees the whole document at once.
if (mountChunked) {
const manager = (
ed.storage as { markdown?: { manager?: MarkdownManagerLike } }
).markdown?.manager;
if (manager) {
ed.commands.setContent(
parseMarkdownChunked(manager, initialContent),
{ emitUpdate: false },
);
} else {
ed.commands.setContent(initialContent, {
emitUpdate: false,
contentType: "markdown",
});
}
}
lastEmittedRef.current = stripBlobUrls(ed.getMarkdown()).trimEnd();
},
content: mountChunked ? "" : initialContent,
contentType: mountChunked
? undefined
: defaultValue
? "markdown"
: undefined,
extensions: createEditorExtensions({
placeholder: placeholderText,
queryClient,
onSubmitRef,
onUploadFileRef,
submitOnEnter,
disableMentions,
mentionMode,
getMentionContextItems: () => mentionContextItemsRef.current,
enableSlashCommands,
slashCommandMode,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (flushPendingOnUnmountRef.current) {
pendingFlushRef.current = stripBlobUrls(ed.getMarkdown()).trimEnd();
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
debounceRef.current = undefined;
pendingFlushRef.current = null;
const md = stripBlobUrls(ed.getMarkdown()).trimEnd();
if (md === lastEmittedRef.current) return;
lastEmittedRef.current = md;
onUpdateRef.current?.(md);
}, debounceMs);
},
onBlur: () => {
onBlurRef.current?.();
},
editorProps: {
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
// Skip links inside NodeView wrappers — they handle their own clicks
if (target.closest("[data-node-view-wrapper]")) return false;
const link = target.closest("a");
const href = link?.getAttribute("href");
if (!href || isMentionHref(href)) return false;
event.preventDefault();
openLink(href, workspaceSlugRef.current);
return true;
},
},
attributes: {
class: cn("flex-1 rich-text-editor text-sm outline-none", className),
},
},
});
// Cleanup on unmount. A pending debounced update is DROPPED by default,
// not flushed — see the `flushPendingOnUnmount` prop doc for why. When the
// owner opted in, emit the markdown cached at `onUpdate` time so a long
// debounce can't swallow the last edit when the surrounding modal closes.
useEffect(() => {
return () => {
if (!debounceRef.current) return;
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
if (!flushPendingOnUnmountRef.current) return;
const pending = pendingFlushRef.current;
pendingFlushRef.current = null;
if (pending === null || pending === lastEmittedRef.current) return;
lastEmittedRef.current = pending;
onUpdateRef.current?.(pending);
};
}, []);
// Sync external `defaultValue` changes into the editor.
// Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831);
// without this effect, a WS-driven description update keeps the editor
// showing stale content until the issue is closed and reopened.
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,
// so a divergence means the editor holds unsaved bytes.
const isDirty =
lastEmittedRef.current !== null && current !== lastEmittedRef.current;
// Guard 1: focused AND dirty — protect bytes the user is actively
// typing. Focused-but-clean falls through: applying setContent is safe
// (no user input to lose) and necessary, because onBlur has no replay
// mechanism and a focused clean editor would otherwise drop this sync
// permanently.
if (editor.isFocused && isDirty) return;
// Guard 2: unfocused-but-dirty — blur happened but the debounce window
// (debounceMs, 1500ms for description) hasn't flushed yet. The pending
// onUpdate will reach the server and the cache will reconcile; skipping
// here avoids overwriting unsaved local edits.
if (isDirty) return;
const incoming = defaultValue ? preprocessMarkdown(defaultValue) : "";
const incomingNormalized = stripBlobUrls(incoming).trimEnd();
// Guard 3: normalized-equal short-circuit. Avoids a no-op transaction
// when the cache reflects a write this same editor just emitted.
if (incomingNormalized === current) return;
// Guard 4: `emitUpdate: false`. Tiptap v3's setContent defaults to
// `emitUpdate: true`; without this we would re-trigger onUpdate →
// server save → self-write loop.
const { from, to } = editor.state.selection;
// Same chunked path on WS-driven re-parse of a large description.
const manager =
incoming.length > MARKDOWN_CHUNK_THRESHOLD
? (editor.storage as { markdown?: { manager?: MarkdownManagerLike } })
.markdown?.manager
: undefined;
if (manager) {
editor.commands.setContent(parseMarkdownChunked(manager, incoming), {
emitUpdate: false,
});
} else {
editor.commands.setContent(incoming, {
emitUpdate: false,
contentType: "markdown",
});
}
// Clamp prior selection to the new doc size so the caret doesn't snap
// to position 0 after ProseMirror replaces the document.
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection({
from: Math.min(from, docSize),
to: Math.min(to, docSize),
});
lastEmittedRef.current = stripBlobUrls(editor.getMarkdown()).trimEnd();
}, [defaultValue, editor]);
useImperativeHandle(ref, () => ({
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
blur: () => {
editor?.commands.blur();
},
uploadFile: (file: File) => {
if (!editor || !onUploadFileRef.current) return;
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
hasActiveUploads: () => {
if (!editor) return false;
let uploading = false;
editor.state.doc.descendants((node) => {
if (node.attrs.uploading) uploading = true;
return !uploading;
});
return uploading;
},
}));
// Link hover card — disabled when BubbleMenu is active (has selection)
const wrapperRef = useRef<HTMLDivElement>(null);
const hoverDisabled = !editor?.state.selection.empty;
const hover = useLinkHover(wrapperRef, hoverDisabled);
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!editor) return;
const target = event.target as HTMLElement;
if (target.closest(".ProseMirror")) return;
if (target.closest("a, button, input, textarea, [role='button'], [data-node-view-wrapper]")) return;
event.preventDefault();
editor.commands.focus("end");
};
if (!editor) return null;
return (
<AttachmentDownloadProvider attachments={providerAttachments}>
<div
ref={wrapperRef}
className="relative flex flex-1 min-h-full flex-col"
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex flex-1 flex-col" editor={editor} />
{showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
<LinkHoverCard {...hover} />
</div>
</AttachmentDownloadProvider>
);
},
);
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };