mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 03:55:27 +02:00
* feat(comments): skip agent triggering on /note-prefixed comments A comment whose first token is the reserved /note prefix (case-insensitive) is stored like any other comment but never wakes an agent. The guard sits at the top of triggerTasksForComment, the single chokepoint, so it covers all three trigger paths — assignee, squad leader, and @mentioned agents. Gating only shouldEnqueueOnComment (as originally proposed) would still let "/note @agent ..." through the mention path. Lets members leave human-only tips/notes on agent-assigned issues without burning an agent run. MUL-3115, closes #3649. Co-authored-by: multica-agent <github@multica.ai> * feat(editor): add /note built-in slash command to comment composer Enable the `/` menu in the issue comment and reply composers in a new "command" mode that lists fixed built-in commands instead of the chat skill picker. Currently one command, /note, which marks a comment as a human-only note that won't trigger the assigned agent. Selecting it inserts the plain-text "/note " prefix (not a rich node), so a menu pick and a hand-typed command are byte-identical and the backend detects either with a simple prefix match. The command menu renders nothing on a non-matching `/` (hideOnEmpty) so typing a date like 6/8 isn't noisy. The chat skill picker is unchanged. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> * refactor(editor): match /note by label prefix and localize its description Address PR review feedback: - buildBuiltinCommandItems now matches the command label as a prefix only, dropping the description substring match copied from the skill picker. With one command this keeps the menu predictable (/no surfaces note; /deploy or a description word like /agent shows nothing) and avoids Enter selecting note unexpectedly. - The command description is now a localized UI string: added slash_command.commands.note to all four editor locales (en/ja/ko/zh-Hans) and the menu renders it via the typed translator. The /label itself stays literal since it's the typed token the backend matches. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> * fix(editor): shorten /note command description to avoid truncation The slash menu item is single-line (truncate, w-72), so the longer copy was cut off. Shorten to "won't trigger any agents" across all four locales — also more accurate, since /note skips assignee, squad leader, and @mentioned agents, not just the assigned one. MUL-3115. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
411 lines
16 KiB
TypeScript
411 lines
16 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,
|
|
useRef,
|
|
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[];
|
|
}
|
|
|
|
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,
|
|
},
|
|
ref,
|
|
) {
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const onUpdateRef = useRef(onUpdate);
|
|
const onSubmitRef = useRef(onSubmit);
|
|
const onBlurRef = useRef(onBlur);
|
|
const onUploadFileRef = useRef(onUploadFile);
|
|
const mentionContextItemsRef = useRef<MentionItem[]>(mentionContextItems ?? []);
|
|
const lastEmittedRef = useRef<string | null>(null);
|
|
|
|
// 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 = onUploadFile;
|
|
mentionContextItemsRef.current = mentionContextItems ?? [];
|
|
|
|
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 (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
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 debounce on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// 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;
|
|
|
|
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={attachments}>
|
|
<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 };
|