Files
multica/packages/views/editor/content-editor.tsx
Jiayuan Zhang 5e3b7a8c37 feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465) (#6132)
* feat(issues): Issue Quick Actions — preset agent + prompt, one-click from the sidebar (MUL-5465)

Preset "who to call and what to say" once in Settings, then trigger it from
any issue's sidebar with a single click.

Running one is NOT a new dispatch path. The server renders the prompt, posts
a `quick_action` comment carrying the target's mention markup, and hands off
to the existing comment -> mention -> task trigger. Permission
(canInvokeAgent), attribution, squad-leader routing, the execution log, and
pending-task coalescing are inherited rather than reimplemented — the
MUL-3375 lesson about four drifting copies of one trigger decision.

Three things the UI has to be honest about, because the backend already
decided them:

- One pending task per (issue, agent) is a DB invariant
  (idx_one_pending_task_per_issue_agent). A second click against a busy agent
  starts no new run; the comment merges into the pending task. The toast says
  "Added to Lambda's current run", not "Lambda started working".
- An offline target defers rather than fails; the run reuses the existing
  dispatch.ReasonCode vocabulary instead of inventing one.
- Private agents are deny-by-default with no admin bypass. The sidebar filters
  by the caller's own invoke verdict, so a dead button is never rendered, and
  a direct API call still 403s with `invocation_not_allowed`.

Visibility is DERIVED from the bound agent's permission_mode on every request,
never stored — so it cannot drift after someone flips an agent between private
and public_to. Binding a workspace action to a private agent is allowed (the
alternative pressures people into making agents public just to satisfy a
config constraint) but the settings form says so at bind time, and the
catalog badges it. The target's name is withheld from callers who cannot see
it, so the response never discloses a private agent's existence.

Prompt templating is flat substitution over a closed whitelist. No
conditionals, loops, or filters — the agent already reads the whole issue, so
natural language is the control flow. One optional runtime input ({{input}})
keeps a single action from splitting into five near-identical variants; both
directions of the input/{{input}} agreement are rejected at write time so a
typo can never land silently.

Surfaces: sidebar (top 5, rest behind More), the `/` menu in the comment
composer (inserts the server-rendered body to edit before sending), and
Alt-click for the same hand-off from the sidebar.

Migrations 234-236: quick_action table, its listing index (CONCURRENTLY, own
file), and comment.type + comment.quick_action_id.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): simplify quick action permissions to a stored public/private intent (MUL-5465)

Replaces the derived four-value visibility model with a two-value choice made
at creation, and collapses permission handling to a single check.

The old model computed visibility per request from the bound agent's
permission_mode and used it to filter the sidebar. That filtering was the
problem: two people on one issue saw different sidebars with nothing to
explain the difference, which is harder to debug than a button that tells you
why it refused. It also required the list endpoint to run an invocation-target
query per action per request.

Now:
  - `visibility` is stored INTENT — 'public' or 'private' — chosen up front.
  - A public action must bind a target every workspace member can invoke
    (public_to carrying a workspace target), enforced at write time. So a
    public action is runnable by construction and dead buttons are eliminated
    at the source rather than filtered out later.
  - A private action allows any target and is returned only to its creator.
    That scoping is what the field MEANS, not a permission check.
  - Permission is checked in exactly one place: RunQuickAction. A refusal is a
    structured 403 the client renders as one dialog. The dialog does not
    distinguish "no permission" from "the binding drifted" — the person
    reading it takes the same next step either way, and the person who can fix
    it looks at settings.

Removed: can_run, position + manual ordering (settings sorted by usage while
the sidebar sorted by position — one list, two orders), the derived
visibility_broken flag, the runnable_only projection and its second cache
entry, target_name redaction, the alt-click composer hand-off (the `/` menu
covers insert-then-edit and is discoverable), and the sidebar_limit response
field (now a shared constant).

Ordering is use_count DESC everywhere. Settings shows the target's current
reachability as plain metadata ("Nova · private"), so a public action pointing
at a now-private agent reads as visibly wrong without a bespoke error state.
The tradeoff — no active signal when that drift happens — was accepted
deliberately: drift is rare and the failure is loud at click time.

Migration 234 is edited in place rather than layered, since the PR is
unmerged and the table has never been deployed.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): drop quick action variables and runtime input (MUL-5465)

V1 ships a preset prompt sent verbatim, triggered from the sidebar or the `/`
slash command. Two features are removed and one guard is kept.

Runtime input goes because `/` already covers it. Typing `/code review` drops
the rendered body into the composer, where any part of it can be edited before
sending — strictly more flexible than one fixed field, and the field was
specified before `/` was in V1. Two UIs for one need.

Variables go because none of them passed their own test. The rule was that a
variable earns its place only if it changes what the agent ATTENDS TO, not what
it KNOWS. Checked one by one — {{issue.title}}, {{issue.identifier}},
{{issue.url}}, {{user.name}}, {{date}} — the agent already has every one from
the issue context and from the fact that the comment is authored by the person
who triggered it. They were inherited from autopilot's title template rather
than justified.

The REJECTION survives the feature: any `{{...}}` is refused at write time,
naming the offending token. Someone carrying the habit over would otherwise
have `{{issue.title}}` rendered literally into an agent's instructions and
never notice — the exact silent-typo failure the whitelist existed to prevent.
The check is a fraction of the interpolation engine it replaces and keeps the
door open to enabling variables later without touching stored data.

Removed: 4 columns (input_enabled/label/placeholder/required),
renderQuickActionPrompt + the variable whitelist + quickActionIssueURL, the
two-way {{input}} agreement logic, the run/render `input` parameter, the
variable insert chips, the entire "Ask for input on click" block, and the
sidebar's Popover branch — every row is now a plain button. The settings
dialog drops from six field groups to four.

Migration 234 is edited in place rather than layered, since the PR is unmerged
and the table has never been deployed.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(settings): align Quick Actions with the Labels/Properties list, then fix what the UI review found (MUL-5465)

The tab used a bespoke card list while its two siblings — Labels and
Properties — share one table layout. These three are the workspace's catalog
of small named things and should read as one surface, so Quick Actions now
uses the same structure: search + primary action row, bordered card, responsive
column grid that collapses to stacked rows under `md`, and an overflow menu
instead of a row of icon buttons. Columns are Name / Runs as / Who / Used /
Updated. The tab joins the max-w-5xl group for the same reason.

A UI review pass over the result found five things, four of which are fixed
here:

- The visibility chooser communicated selection through border and background
  only, so a screen reader announced both options identically. Added
  aria-pressed.
- The editor dialog was max-w-xl while both siblings use sm:max-w-lg, and the
  unprefixed cap applied at every breakpoint.
- The empty-state hint diverged from the Properties tab it was copied from
  (text-sm and no max width vs mx-auto max-w-sm text-xs).
- Two hardcoded `text-amber-600 dark:text-amber-400` usages replaced with the
  `text-warning` semantic token, per the repo's design-token rule.

Also fixed a signal-quality bug the review surfaced: the usage column
highlighted anything with use_count 0, so an action was flagged the instant it
was created. Staleness now means "has had time to be used and wasn't" — 90
days since last use, or 90 days since creation for one never used.

Not fixed here: the overflow trigger is size-7 (28px), under the 44px touch
floor. Labels and Properties use the identical size, so changing only this tab
would break the consistency this commit exists to create; it needs one pass
across all three.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): drop the quick_action comment type, widen the mention guard, harden the slash race (MUL-5465)

Second review round on PR #6132. All four remaining findings.

**Comment type removed entirely (#2 blocker + #3).** Adding a `quick_action`
type meant dropping and re-adding comment_type_check, and re-adding a CHECK
holds ACCESS EXCLUSIVE on `comment` for a full table scan — a read/write stall
on one of the hottest tables in the product, every deploy. It was also
forgeable: `type` is client-supplied on POST /comments, so any member could
post type='quick_action' and have an ordinary comment render as an action
audit record with its body collapsed out of view.

Both go away by not having the type. A quick action now posts an ORDINARY
comment marked with `quick_action_id`, and the collapsed card keys off that id.
There is no request field for it, so the marker cannot be forged, and the
migration is a bare nullable ADD COLUMN — metadata-only and instant. Verified
against a fresh database: comment_type_check is untouched.

The generic comment endpoint now also validates `type` instead of letting the
DB CHECK reject it. An unknown type surfaced as a 500 on a constraint
violation, which reads as a server fault for plainly bad input; it is a 400
now. `status_change` and `system` are excluded from what a client may author —
claiming those would be forging system narration.

**Member mentions rejected too (#1).** The first pass allowed
`mention://member/...` in prompts on the reasoning that it "only renders a
link". That was wrong: notification_listeners.go adds member mentions to the
recipient set and creates an inbox item, so a saved prompt pinged that person
on every single click. Only `mention://issue/...` reaches nobody and stays
allowed.

**Slash race, properly this time (#4).** The previous fix checked only that the
range still started with "/". Rewriting `/review` into `/fix` while the request
was open passed that check, and the stale response overwrote the new command.
The exact original text is now captured and compared; if the command was
edited, moved, or removed, the pick is abandoned rather than inserted
somewhere wrong. Adds the three regression tests the review asked for:
delayed resolve, rejection, and edit-during-flight.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): stop the quick action card repeating its own prompt, and insert the `/` body as markdown (MUL-5465)

Two fixes, one reported and one found while verifying it.

**The card printed the prompt twice.** The collapsed header previewed the
prompt's first line, and expanding showed the mention line plus that same
prompt again. The header now identifies WHICH action ran — "Code Review via
Lambda" — which is both non-redundant and something the body never told you:
the prompt text alone does not say which action produced it. This is what the
original design called for; previewing the prompt was the implementation
drifting from it.

When the action cannot be resolved — deleted, or another member's private one
and so absent from this viewer's catalog — the header falls back to the
prompt's opening line, which is the previous behaviour.

**The `/` menu inserted its body as literal text.** insertContentAt was called
with a plain string, so Tiptap treated the server-rendered markdown as text
rather than parsing it. The mention never became a node; it serialised back out
with escaped brackets (`\[@Lambda\](mention://agent/…)`) and rendered as raw
markup in the thread. Passing `contentType: "markdown"` — the same option the
description editor already uses — parses it properly. Found by reading the
comment rows while checking the first fix: one had escaped brackets and no
quick_action_id, which is what a slash-inserted comment looked like.

The existing async test now asserts the contentType, so the option cannot be
dropped again without failing.

Co-authored-by: multica-agent <github@multica.ai>

* docs(issues): correct the stale quick actions sidebar comment (MUL-5465)

The comment still claimed the section renders nothing when no action is
runnable by the member. Permission filtering was removed several rounds
ago -- the list is deliberately unfiltered and a refusal is explained at
run time -- so the comment described behavior that no longer exists.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(settings): cut the quick action dialog's helper copy in half (MUL-5465)

The dialog had five blocks of explanatory prose around four fields, and
three of them wrapped to two lines, so the form read as a paragraph with
inputs in it.

Each helper now earns its line or loses it:

- The header explained the implementation ("keeps the same history,
  permissions, and execution log as an @mention") -- an architecture note
  the person creating an action does not need. Reduced to the one fact
  they do: it posts a comment.
- "Who can use it" is a question, so the hints answer it as noun phrases
  ("Everyone in the workspace" / "Only you") instead of restating the
  verb. Both now fit one line, which also makes the two cards the same
  height -- the shorter one used to sit in dead space.
- The target and prompt hints front-load the constraint rather than
  burying it mid-sentence.

70 words to 32 across the dialog, with no fact dropped. Field spacing
goes 4 -> 5 so the gap between groups beats the gap inside one.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): render a quick action comment as an ordinary comment (MUL-5465)

The card had a collapsed one-line header that expanded to reveal the
prompt, on the theory that repeated runs of the same action would bury
the discussion. That was solving a problem the feature does not have:
prompts are a sentence or two, the header restated what the body already
said, and the disclosure only put a click between the reader and the
text.

A quick action posts a real comment through the real mention path, so
the honest rendering is the one every other comment gets. Drops
QuickActionCommentBody, its query for the action catalog, and the
now-orphaned quick_action_ran_via string in all four locales.

quick_action_id stays on the comment: it is provenance, and it was never
the reason the card looked different -- keying the special rendering off
it is what is going away, not the record itself.

Co-authored-by: multica-agent <github@multica.ai>

* fix(settings): use the faint tone token for the empty-state icon (MUL-5465)

main added apps/web/app/text-contrast.test.ts, a guard that rejects
transparency standing in for a text tone. The empty-state Zap used
text-muted-foreground/60, which is exactly the pattern it forbids: an
alpha-dimmed tone lands at a different contrast on every surface it is
composited over, so it cannot be reasoned about the way a token can.

text-faint-foreground is the token the guard names for icons and glyphs.

The rule arrived on main after this branch's last merge, so local runs
never saw it -- CI tests the merge commit, which is why only CI caught
it. Merged main first so the branch is checked against the same rules.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 21:01:48 +08:00

970 lines
44 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 (ProseMirror wrapper), @tiptap/markdown for
* bidirectional Markdown ↔ ProseMirror JSON conversion.
*/
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor, EditorContent, type Editor } 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 { issueIdentifierOptions } from "@multica/core/issues/queries";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { isIssueIdentifier } from "@multica/ui/markdown";
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 type { IssueIdentifierResolver } from "./extensions/issue-identifier-autolink";
import type { BuiltinCommandSuggestionOptions } from "./extensions/slash-command-suggestion";
import { createEditorExtensions } from "./extensions";
import {
uploadAndInsertFile,
insertUploadPlaceholder,
settleUploadNode,
} from "./extensions/file-upload";
import { configStore } from "@multica/core/config";
import { preprocessMarkdown } from "./utils/preprocess";
import { repairEmptyListItems } from "./utils/repair-list-items";
import { useAppOrigin } from "../navigation";
import { openLink, isMentionHref } from "./utils/link-handler";
import { EditorBubbleMenu } from "./bubble-menu";
import { posFromAnchor, type TextAnchor } from "./text-anchor";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { AttachmentDownloadProvider } from "./attachment-download-context";
import "katex/dist/katex.min.css";
import "./styles/index.css";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Canonical comparison form for a markdown string: drop the blank lines a
* block leaves behind at either end, so both sides of a dirty check compare
* like-for-like.
*
* This used to also strip `blob:` image lines with a regex, because an
* in-flight image serialised its process-local blob URL into the body. That
* is now impossible at the source: the image and fileCard `renderMarkdown`
* implementations emit nothing while `attrs.uploading` is set, so a
* placeholder never becomes text that has to be scrubbed back out.
*
* Leading blank lines are trimmed too, not just trailing: a placeholder in
* the FIRST block leaves its blank line at the head of the document, and a
* draft that opens with an empty line is the same content as one that does
* not. (The old regex left one such newline behind for the same reason.) */
function normalizeMarkdown(md: string): string {
return md.trim();
}
/** `normalizeMarkdown` applied to the live editor's serialized content. */
function normalizeEditorMarkdown(editor: Editor): string {
return normalizeMarkdown(editor.getMarkdown());
}
/** True when any node in the document is mid-upload (`attrs.uploading`). The
* `return !found` early-out matches the original inline scans verbatim: in
* ProseMirror it only stops descending into the matched node's subtree (not
* the whole walk), but once `found` flips true the boolean result is fixed. */
function hasUploadingNode(editor: Editor): boolean {
let found = false;
editor.state.doc.descendants((node) => {
if (node.attrs.uploading) found = true;
return !found;
});
return found;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ContentEditorBaseProps {
onUpdate?: (markdown: string) => void;
placeholder?: string;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onBlur?: () => void;
/**
* Upload transport. `uploadId` is minted by the editor when it inserts the
* placeholder node; a host backed by a draft store MUST adopt it as the
* upload's `clientUploadId` so both records share one identity — that is
* what lets a settle find its placeholder in a document a later mount
* rebuilt. Hosts with no persistence may ignore it.
*/
onUploadFile?: (file: File, uploadId: string) => Promise<UploadResult | null>;
/**
* Character count above which a plain-text paste is uploaded as a
* `pasted-text.txt` attachment instead of being inserted as body text.
* Requires `onUploadFile`; without an uploader the paste stays text.
*
* Opt-in ON PURPOSE, and today only chat passes it: a wall of pasted text
* there is context handed to an agent for one turn, and reads better as an
* attachment than as a body nobody scrolls. Every other editor keeps the
* paste inline — in issue and project descriptions a long paste IS the
* content, and in issue comments it is prose a human reader is expected to
* see in the thread.
*/
pasteAsFileThreshold?: number;
/**
* Fired whenever this editor's "any attachment still uploading" answer
* flips. The document IS the upload queue — every path (paste, drop, the
* upload button, the imperative `uploadFile`) inserts a node with
* `attrs.uploading` before awaiting, and clears or removes it on settle —
* so hosts can drive a submit gate off one source of truth instead of a
* counter of their own that a manual node delete would desync.
*
* Pair it with the submit-time `hasActiveUploads()` second check: this
* callback drives the rendered button state, and the ref read is what a
* keyboard submit racing the last upload's settle must consult.
*
* Hosts that don't gate (the autosaved description editor) omit it and
* pay nothing — the scan below is skipped entirely when it's absent.
*/
onUploadingChange?: (uploading: boolean) => void;
/** Show the floating formatting toolbar on text selection. Defaults true. */
showBubbleMenu?: 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";
/**
* Quick actions to offer in the "command" `/` menu, plus the resolver that
* turns a pick into the text it would post (MUL-5465). Read through
* functions so a newly created action appears without remounting the editor.
*/
quickActionMenu?: BuiltinCommandSuggestionOptions;
/**
* 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;
/**
* Called once when the Tiptap instance exists and its initial content is
* set (creation is deferred past first paint by `immediatelyRender: false`).
* Readonly-first hosts such as comment and reply composers use this as the
* signal to swap their static shell for the live editor.
*/
onReady?: () => void;
}
type ContentEditorValueProps =
| {
/** Initial markdown, read once when the editor mounts. */
defaultValue?: string;
value?: never;
}
| {
/**
* Externally synchronized markdown. Use only when changes from outside
* this editor must replace its document (for example realtime server
* updates or switching the document held by a stable editor instance).
*/
value: string;
defaultValue?: never;
};
type ContentEditorProps = ContentEditorBaseProps & ContentEditorValueProps;
interface ContentEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
/**
* Focus and place the caret at the document position under the given
* viewport coordinates. Used by readonly-first hosts so the click that
* summoned the editor lands the caret where the user clicked, matching
* the always-mounted editor's behavior. Falls back to focusing the end
* when no position resolves (click below the last line). Must be called
* while the editor element is laid out (not display: none).
*/
focusAtCoords: (coords: { x: number; y: number }) => void;
/**
* Focus and place the caret at the document position a text anchor
* resolves to. Preferred over `focusAtCoords` for readonly-first hosts:
* the anchor is a logical position ("block N, character M"), so it is
* immune to layout differences between the readonly render and the
* editor render — which is exactly where pixel coordinates drift on
* long documents.
*/
focusAtAnchor: (anchor: TextAnchor) => void;
/** Drop focus from the editor. Used by `useComposerSubmit`'s
* `afterAccepted: "blur"` on surfaces where a send ends the turn, so the
* composer stops reading as "still writing". */
blur: () => void;
uploadFile: (file: File) => void;
/** True when file uploads are still in progress. */
hasActiveUploads: () => boolean;
/**
* Append a markdown fragment to the end of the document (parsed, not raw
* text), firing the normal `onUpdate` pipeline. For the upload write-back
* path (MUL-5181): an upload that outlived the mount that started it settles
* while a NEW editor instance is showing the same draft — that editor never
* owned the upload's promise, so this is how the finished attachment's link
* lands in the visible document instead of only in the persisted draft.
*
* Returns whether the insert actually landed. The imperative handle exists
* from the component's first commit, but the Tiptap instance is created in a
* passive effect — in that window (and after destroy) this is a no-op and
* returns false so the caller can fall back or retry instead of silently
* losing the fragment.
*/
insertMarkdownAtEnd: (markdown: string) => boolean;
/**
* Draw a placeholder for an upload this document is not showing yet, and
* report whether it landed.
*
* A composer that reopens over an upload a previous mount started has the
* draft's record of it but no node — placeholders are never serialised, so
* they die with the document that drew them. Without this the user faces a
* composer that looks idle while a send gate quietly blocks on the upload.
* Returns false when the Tiptap instance is not up yet (the handle exists
* from first commit, the instance arrives a passive effect later), so the
* caller can retry rather than assume.
*/
insertUploadPlaceholder: (upload: {
uploadId: string;
filename: string;
size?: number;
}) => boolean;
/**
* Turn a placeholder into the finished attachment, in place. False when this
* document holds no node for the id — the caller then falls back to
* appending the link.
*/
settleUploadPlaceholder: (uploadId: string, result: UploadResult) => boolean;
/**
* Cancel the pending debounced `onUpdate` and hand its markdown back to the
* caller instead of firing it. Returns null when nothing is pending.
*
* For hosts that re-point ONE editor instance at a different destination
* (chat swaps `draftKey` between sessions). A debounce armed under the old
* destination would otherwise fire after the switch and, because `onUpdate`
* always resolves to the latest render's closure, write the old document
* into the NEW destination. Taking the markdown back lets the host commit it
* where it was actually typed. Flushing also marks the editor clean, so the
* dirty guard stops suppressing the incoming synchronized `value`.
*
* Distinct from `flushPendingOnUnmount`: this is for a LIVE editor changing
* targets, so it reads the current document rather than a cached copy.
*/
flushPendingUpdate: () => string | null;
/**
* Force `markdown` into the document, bypassing the synchronized `value`
* guards.
*
* Those guards SKIP permanently rather than defer: `lastSyncedValueRef`
* advances before they run, so a `value` they refuse is never
* re-applied. That is correct for their usual case (the cache will send
* another value), but not for a host that re-points ONE instance at a
* different document and must land it exactly once — chat's draft switch,
* where an in-flight upload makes Guard 0 refuse the swap.
*
* The caller owns the safety the guards normally provide: only call once the
* reason for the block is gone (upload settled) and any pending edits have
* been flushed, or this destroys them.
*/
adoptContent: (markdown: string) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
function ContentEditor(
{
defaultValue,
value,
onUpdate,
placeholder: placeholderText = "",
className,
debounceMs = 300,
onSubmit,
onBlur,
onUploadFile,
pasteAsFileThreshold,
onUploadingChange,
showBubbleMenu = true,
currentIssueId,
disableMentions = false,
mentionMode = "default",
mentionContextItems,
enableSlashCommands = false,
slashCommandMode = "skill",
quickActionMenu,
attachments,
flushPendingOnUnmount = false,
onReady,
},
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 onReadyRef = useRef(onReady);
const onUploadingChangeRef = useRef(onUploadingChange);
const onUploadFileRef = useRef<
((file: File, uploadId: string) => Promise<UploadResult | null>) | undefined
>(undefined);
// Same reasoning as placeholderRef below: the extension array is built once
// at mount, so the paste-as-file threshold is read through a ref to stay
// live without remounting the editor.
const pasteAsFileThresholdRef = useRef<number | undefined>(pasteAsFileThreshold);
const mentionContextItemsRef = useRef<MentionItem[]>(mentionContextItems ?? []);
// Kept in a ref for the same reason as mentionContextItems: the extension
// set is built once at mount, so a directly-captured options object would
// freeze whatever closures existed then and stop seeing new quick actions.
const quickActionMenuRef = useRef<BuiltinCommandSuggestionOptions | undefined>(quickActionMenu);
const lastEmittedRef = useRef<string | null>(null);
// `content` already consumes the initial synchronized value when Tiptap
// mounts. Track later changes separately so the sync effect does not parse
// the initial document twice when Markdown serialization canonicalizes it.
const lastSyncedValueRef = useRef(value);
// Live placeholder text. Passed into the Placeholder extension as a getter
// (not a static string) so the plugin re-reads it on every decoration pass —
// the sync effect below updates this ref and nudges a repaint. Tiptap
// snapshots a *string* placeholder at mount, so a getter is what lets it
// change without remounting the editor.
const placeholderRef = useRef(placeholderText);
// 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, uploadId: string): Promise<UploadResult | null> => {
const result = await onUploadFile(file, uploadId);
// 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;
// Same reasoning for this deployment's app origin: it tells openLink which
// absolute URLs address this app and must route in-app instead of opening
// the system browser.
const appOrigin = useAppOrigin();
const appOriginRef = useRef(appOrigin);
appOriginRef.current = appOrigin;
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onReadyRef.current = onReady;
onUploadingChangeRef.current = onUploadingChange;
onUploadFileRef.current = wrappedOnUploadFile;
pasteAsFileThresholdRef.current = pasteAsFileThreshold;
mentionContextItemsRef.current = mentionContextItems ?? [];
quickActionMenuRef.current = quickActionMenu;
flushPendingOnUnmountRef.current = flushPendingOnUnmount;
const queryClient = useQueryClient();
// Linear-style bare identifier autolink resolver. Fully lazy — it runs only
// on user input, never on render, so it adds no query hook to this widely
// used component. It reads the current workspace from the query cache (via
// the slug ref) and returns null outside a workspace, for non-identifier
// tokens, or when the prefix can't match this workspace, so no network call
// happens for those; the exact-match filter enforces correctness.
const resolveIssueIdentifierRef = useRef<IssueIdentifierResolver | undefined>(
undefined,
);
resolveIssueIdentifierRef.current = async (identifier) => {
if (!isIssueIdentifier(identifier)) return null;
const slug = workspaceSlugRef.current;
if (!slug) return null;
const workspaces = await queryClient.fetchQuery(workspaceListOptions());
const ws = workspaces.find((w) => w.slug === slug);
if (!ws) return null;
const prefix = ws.issue_prefix;
if (
prefix &&
!identifier.toUpperCase().startsWith(`${prefix.toUpperCase()}-`)
) {
return null;
}
const issue = await queryClient.fetchQuery(
issueIdentifierOptions(ws.id, identifier),
);
return issue ? { id: issue.id, identifier: issue.identifier } : null;
};
const initialMarkdown = value ?? defaultValue ?? "";
const initialContent = initialMarkdown
? // One-shot read: the editor preprocesses its initial document once at
// load. Unlike the readonly renderer it does not need to re-run when
// the CDN config lands later — the user's own edits drive the document
// from here on.
preprocessMarkdown(initialMarkdown, {
cdnDomain: configStore.getState().cdnDomain,
})
: "";
// With `immediatelyRender: false` the Tiptap instance is created after
// mount, so an imperative `focus()` fired on the same tick (e.g. chat
// auto-focusing a brand-new conversation) would hit a null editor and no-op.
// Latch the intent here and honor it in `onCreate` once the editor exists.
const focusOnReadyRef = useRef(false);
// 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,
// 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",
});
}
}
// A markdown draft ending in an empty list item (e.g. `"1. \n\n"` left
// after typing `1.`) parses into a caretless, schema-invalid item;
// repair it so the mounted editor has a real cursor in the list.
repairEmptyListItems(ed);
lastEmittedRef.current = normalizeEditorMarkdown(ed);
if (focusOnReadyRef.current) {
focusOnReadyRef.current = false;
ed.commands.focus("end");
}
},
content: mountChunked ? "" : initialContent,
contentType: mountChunked
? undefined
: initialMarkdown
? "markdown"
: undefined,
extensions: createEditorExtensions({
placeholder: () => placeholderRef.current,
queryClient,
onSubmitRef,
onUploadFileRef,
pasteAsFileThresholdRef,
disableMentions,
mentionMode,
getMentionContextItems: () => mentionContextItemsRef.current,
enableSlashCommands,
slashCommandMode,
quickActionMenu: {
getQuickActions: () => quickActionMenuRef.current?.getQuickActions?.() ?? [],
renderQuickAction: (id: string) =>
quickActionMenuRef.current?.renderQuickAction?.(id) ?? Promise.resolve(""),
onRenderError: (error: unknown) =>
quickActionMenuRef.current?.onRenderError?.(error),
},
resolveIssueIdentifierRef,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (flushPendingOnUnmountRef.current) {
pendingFlushRef.current = normalizeEditorMarkdown(ed);
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
debounceRef.current = undefined;
pendingFlushRef.current = null;
const md = normalizeEditorMarkdown(ed);
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, appOriginRef.current);
return true;
},
},
attributes: {
class: cn("flex-1 rich-text-editor text-body outline-none", className),
},
},
});
// Signal hosts that the deferred editor instance now exists. Fired from a
// passive effect (not `onCreate`) so it runs after the commit in which
// <EditorContent> attached the editor DOM — callers can measure/focus it.
const readyFiredRef = useRef(false);
useEffect(() => {
if (!editor || readyFiredRef.current) return;
readyFiredRef.current = true;
onReadyRef.current?.();
}, [editor]);
// Publish upload-queue transitions to the host so it can gate submit.
//
// Deliberately NOT derived from `onUpdate`: that path is debounced and
// drops emissions whose markdown matches the last one — and a FAILED
// upload removes its placeholder to leave byte-identical markdown (the
// blob URL was stripped from it all along), so the un-gate would never
// fire. Transactions carry the attr flip regardless of what serializes.
useEffect(() => {
if (!editor || !onUploadingChange) return;
// Publish the current answer UNCONDITIONALLY on subscribe, then only on
// flips. The host's state outlives any one editor instance — comment
// edit unmounts the editor on cancel and mounts a fresh one on re-entry,
// and chat swaps the editor by `key` when the agent changes. An editor
// torn down mid-upload takes its pending node with it, so a host left
// holding `uploading: true` has nothing left to un-gate it: skipping
// this first emission because the new instance also reads "not
// uploading" is exactly how submit gets wedged shut for good.
//
// `last` is per-subscription rather than a ref for the same reason: flip
// tracking must not survive the instance it describes.
let last = hasUploadingNode(editor);
onUploadingChangeRef.current?.(last);
const check = () => {
if (editor.isDestroyed) return;
const uploading = hasUploadingNode(editor);
if (uploading === last) return;
last = uploading;
onUploadingChangeRef.current?.(uploading);
};
editor.on("transaction", check);
return () => {
editor.off("transaction", check);
};
// `onUploadingChange` is read for presence only; the ref carries the
// live callback, so a host passing an inline arrow doesn't rebind.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, !!onUploadingChange]);
// 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);
};
}, []);
// Replace the live document with external markdown. Shared by the
// synchronized `value` effect below and the imperative `adoptContent`, so
// both land content identically — chunked parse for large docs, no
// onUpdate echo, caret preserved. Callers own the decision to apply;
// the guards live in the effect, not here.
const applyExternalContent = useCallback(
(markdown: string) => {
if (!editor || editor.isDestroyed) return;
const before = normalizeEditorMarkdown(editor);
// A controlled host commonly echoes the exact Markdown this editor
// just emitted. Recognize that acknowledgment before preprocessing:
// preprocessing a half-typed token can change its meaning (for
// example `dev.de` used to become a link during a debounce pause).
if (normalizeMarkdown(markdown) === before) return;
const incoming = markdown
? preprocessMarkdown(markdown, {
cdnDomain: configStore.getState().cdnDomain,
})
: "";
const incomingNormalized = normalizeMarkdown(incoming);
// Normalized-equal short-circuit. Avoids a no-op transaction when the
// preprocessed input serializes to the document already on screen.
if (incomingNormalized === before) return;
// `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",
});
}
// An empty list item in the incoming markdown parses into a caretless,
// schema-invalid node; repair it and let it own the caret. Otherwise clamp
// the prior selection to the new doc size so the caret doesn't snap to
// position 0 after ProseMirror replaces the document.
if (!repairEmptyListItems(editor, { from, to })) {
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection({
from: Math.min(from, docSize),
to: Math.min(to, docSize),
});
}
lastEmittedRef.current = normalizeEditorMarkdown(editor);
},
[editor],
);
// Sync explicit external `value` changes into the editor. `defaultValue`
// is deliberately mount-only, matching React's normal uncontrolled-input
// contract; drafts therefore cannot feed their own debounced writes back
// into the live 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 || value === undefined) return;
const previousValue = lastSyncedValueRef.current;
lastSyncedValueRef.current = value;
// The initial value was already parsed through useEditor's
// `content` option (or in onCreate for the chunked path). Comparing that
// source Markdown to Tiptap's canonical serialization can differ even
// when they represent the same document, and used to cause an immediate
// second full parse. Only later prop changes belong to this sync effect.
if (value === previousValue) return;
// Guard 0: never clobber an in-flight upload. An external `value`
// change can arrive mid-upload — e.g. chat lazy-creates a session on the
// first file upload, which flips `activeSessionId` → the draft key →
// `value`. 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.
//
// NOTE: this (like every guard here) SKIPS the sync permanently for this
// value — `lastSyncedValueRef` has already advanced, so the effect will
// not re-run for it. A host that must still land this content once the
// block clears has to say so explicitly, via `adoptContent`.
if (hasUploadingNode(editor)) return;
const current = normalizeEditorMarkdown(editor);
// "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;
applyExternalContent(value);
}, [value, editor, applyExternalContent]);
// Sync external `placeholder` changes into the mounted editor.
// The Placeholder extension is configured with a getter over `placeholderRef`
// (see createEditorExtensions above), which the plugin re-invokes every time
// it recomputes its decorations. Update the ref, then dispatch an empty
// transaction to force that recompute — the placeholder refreshes without a
// remount. Without this, it stays frozen at its mount value: switching
// between an archived and an active chat session under the same agent (no
// editor remount) leaves the input stuck on "This session is archived" even
// though it is usable.
useEffect(() => {
if (placeholderRef.current === placeholderText) return;
placeholderRef.current = placeholderText;
if (!editor || editor.isDestroyed) return;
// `docChanged` is false on an empty transaction, so onUpdate never fires
// and no self-write loop is triggered.
editor.view.dispatch(editor.state.tr);
}, [editor, placeholderText]);
useImperativeHandle(ref, () => ({
// Intentionally NOT routed through `normalizeMarkdown` — see the "stays
// untrimmed" safety net in content-editor.test.tsx. It used to also pass
// through `stripBlobUrls`; that wrapper is gone because an in-flight
// placeholder no longer serialises at all, which is strictly stronger
// than scrubbing it back out afterwards.
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
if (editor) editor.commands.focus();
// Editor not mounted yet — defer the focus to `onCreate`.
else focusOnReadyRef.current = true;
},
focusAtCoords: (coords: { x: number; y: number }) => {
if (!editor) {
// Editor not mounted yet — degrade to the latched plain focus.
focusOnReadyRef.current = true;
return;
}
const pos = editor.view.posAtCoords({ left: coords.x, top: coords.y });
if (pos) editor.commands.focus(pos.pos);
else editor.commands.focus("end");
},
focusAtAnchor: (anchor: TextAnchor) => {
if (!editor) {
// Editor not mounted yet — degrade to the latched plain focus.
focusOnReadyRef.current = true;
return;
}
editor.commands.focus(posFromAnchor(editor.state.doc, anchor));
},
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: () => (editor ? hasUploadingNode(editor) : false),
insertUploadPlaceholder: (upload) => {
if (!editor || editor.isDestroyed) return false;
return insertUploadPlaceholder(editor, upload);
},
settleUploadPlaceholder: (uploadId, result) => {
if (!editor || editor.isDestroyed) return false;
return settleUploadNode(editor, uploadId, result);
},
insertMarkdownAtEnd: (markdown: string) => {
if (!editor || editor.isDestroyed) return false;
editor.commands.insertContentAt(editor.state.doc.content.size, markdown, {
contentType: "markdown",
});
return true;
},
flushPendingUpdate: () => {
// No armed timer = nothing typed since the last emit. The editor is
// already clean, so the host has nothing to re-route.
if (!debounceRef.current) return null;
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
pendingFlushRef.current = null;
if (!editor || editor.isDestroyed) return null;
// Read the live document: unlike the unmount flush, the instance is
// still alive here, so this is the freshest possible copy.
const md = normalizeEditorMarkdown(editor);
if (md === lastEmittedRef.current) return null;
// Advance the emit watermark so the editor reads as clean — the host
// is taking responsibility for these bytes, and the dirty guard must
// now let the incoming synchronized value through.
lastEmittedRef.current = md;
return md;
},
adoptContent: (markdown: string) => applyExternalContent(markdown),
}));
// 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 };