mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators edit agent instructions, routine descriptions, and other Markdown fields in the web UI > - All of these fields use the shared `MarkdownEditor`, which wraps MDXEditor > - MDXEditor re-serializes the initial Markdown when it loads, and it sends the result through `onChange` > - MDXEditor marks that event with the second `onChange` argument, `initialMarkdownNormalize`, but `MarkdownEditor` ignores the flag > - Thus each parent with dirty tracking shows unsaved changes before the user types, and each parent must add its own interaction gate (#12502, #13485) > - This pull request drops the flagged event inside `MarkdownEditor` > - The benefit is that no consumer reports unchanged content as modified on load, and new consumers do not need their own gate ## Linked Issues or Issue Description Refs #13472 Related: - #12502 (merged) adds an interaction gate for agent instructions in `AgentDetail.tsx`. - #13485 (open) adds the same gate to the two routine description editors. Its description names "a shared fix inside `MarkdownEditor`" as a follow-up. This PR is that follow-up. It does not conflict with #13485. The two changes can merge in any order. ## What Changed - `MarkdownEditor.tsx`: read the second `onChange` argument (`initialMarkdownNormalize`). When it is `true`, do not call the parent `onChange`. - The check runs after the empty-document mount guard. That guard still restores content when the first event is empty. - `latestValueRef` keeps the parent value. Thus the prop-sync effect does not push the original Markdown back into the editor. - `MarkdownEditor.test.tsx`: the MDXEditor mock can now send a flagged normalization event on mount. A new test makes sure that the parent `onChange` does not run and that the editor keeps its content. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/MarkdownEditor.test.tsx`: 48 tests pass. - The new test fails without the change ("expected vi.fn() to not be called at all, but actually been called 1 times"). It passes with the change. - `pnpm --filter @paperclipai/ui exec vitest run` (full UI suite): 634 test files and 6653 tests pass. - `pnpm --filter @paperclipai/ui typecheck`: passes with 0 errors. - I read the MDXEditor 4.2.3 source (`dist/plugins/core/index.js`). The flag is `true` only for the first export after the initial Markdown is set. The editor sets it to `false` after that export. User edits and `setMarkdown` echoes get `false`. - Manual check: we tested the same guard in a real browser on an older Paperclip base (MDXEditor 3.55), together with two changes that `master` already has from #12502. We opened an agent Instructions tab with `-` bullets. The Save bar and the unsaved-changes prompt did not show until we typed. After an edit, one prompt showed for each navigation, and Save still stored the edit. Manual steps for a reviewer: 1. Put an instruction file with `-` bullets on an agent, for example `- first` and `- second`. 2. Open the agent Instructions tab. Do not type. 3. Make sure that the Save bar does not show. 4. Go to another tab. Make sure that no unsaved-changes prompt shows. 5. Do the same steps with a routine description that has `-` bullets. ## Risks - Low risk. The change affects only the first `onChange` event after the editor loads. - A consumer that uses the first event to learn the normalized Markdown no longer gets it. We found no such consumer in the 24 files that render `MarkdownEditor`, and the full UI suite passes. - There are no API, database, migration, or visual changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 1M-token context window, run as a Claude Code agent with tool use (shell, file edits, test runs). The model wrote the code, the test, and this description. A human operator approved them before submission. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes (no docs describe this behavior) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending: CI runs after push) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending: runs after push) - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1733 lines
63 KiB
TypeScript
1733 lines
63 KiB
TypeScript
import { AgentAvatar } from "./AgentAvatar";
|
|
import {
|
|
Component,
|
|
type ClipboardEvent,
|
|
type ErrorInfo,
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type DragEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
type TouchEvent as ReactTouchEvent,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
CodeMirrorEditor,
|
|
MDXEditor,
|
|
codeBlockPlugin,
|
|
codeMirrorPlugin,
|
|
type CodeBlockEditorDescriptor,
|
|
type MDXEditorMethods,
|
|
headingsPlugin,
|
|
imagePlugin,
|
|
linkDialogPlugin,
|
|
linkPlugin,
|
|
listsPlugin,
|
|
markdownShortcutPlugin,
|
|
quotePlugin,
|
|
tablePlugin,
|
|
thematicBreakPlugin,
|
|
defaultSvgIcons,
|
|
type IconKey,
|
|
type RealmPlugin,
|
|
} from "@mdxeditor/editor";
|
|
import {
|
|
buildAgentMentionHref,
|
|
buildIssueReferenceHref,
|
|
buildProjectMentionHref,
|
|
buildRoutineMentionHref,
|
|
buildUserMentionHref,
|
|
} from "@paperclipai/shared";
|
|
import { Boxes, CalendarClock, Flag, Hash, User, X } from "lucide-react";
|
|
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
|
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
|
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
|
import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
|
|
import { normalizeMarkdown } from "../lib/normalize-markdown";
|
|
import {
|
|
escapeUnsupportedAngleBrackets,
|
|
unescapeAngleBracketEscapes,
|
|
} from "../lib/angle-bracket-markdown";
|
|
import { unescapeBlockquoteMarkers } from "../lib/blockquote-markdown";
|
|
import { pasteNormalizationPlugin } from "../lib/paste-normalization";
|
|
import { cn } from "../lib/utils";
|
|
import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext";
|
|
|
|
/* ---- Mention types ---- */
|
|
|
|
export interface MentionOption {
|
|
id: string;
|
|
name: string;
|
|
kind?: "agent" | "project" | "user" | "issue";
|
|
agentId?: string;
|
|
agentIcon?: string | null;
|
|
agentAppearance?: import("@paperclipai/shared").AgentAppearance | null;
|
|
projectId?: string;
|
|
projectColor?: string | null;
|
|
userId?: string;
|
|
/** Issue/task references (PAP-95f). `name` carries the searchable identifier + title. */
|
|
issueId?: string;
|
|
issueIdentifier?: string;
|
|
}
|
|
|
|
/* ---- Editor props ---- */
|
|
|
|
interface MarkdownEditorProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
ariaLabel?: string;
|
|
placeholder?: string;
|
|
className?: string;
|
|
contentClassName?: string;
|
|
onBlur?: () => void;
|
|
imageUploadHandler?: (file: File) => Promise<string>;
|
|
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
|
|
onDropFile?: (file: File) => Promise<void>;
|
|
/** When set to `parent`, a wrapper owns drag/drop behavior and visuals. */
|
|
fileDropTarget?: "editor" | "parent";
|
|
bordered?: boolean;
|
|
/** List of mentionable entities. Enables @-mention autocomplete. */
|
|
mentions?: MentionOption[];
|
|
/** Capability-aware action commands supplied by the owning composer. */
|
|
actionCommands?: SlashCommandOption[];
|
|
/** Called on Cmd/Ctrl+Enter */
|
|
onSubmit?: () => void;
|
|
/** Render the rich editor without allowing edits. */
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export interface MarkdownEditorRef {
|
|
focus: () => void;
|
|
insertMarkdown: (markdown: string) => void;
|
|
clear: () => void;
|
|
}
|
|
|
|
class MarkdownEditorRichErrorBoundary extends Component<
|
|
{ children: ReactNode; onError: (error: unknown) => void },
|
|
{ hasError: boolean }
|
|
> {
|
|
state = { hasError: false };
|
|
|
|
static getDerivedStateFromError() {
|
|
return { hasError: true };
|
|
}
|
|
|
|
componentDidCatch(error: unknown, info: ErrorInfo) {
|
|
console.error("Markdown rich editor failed; falling back to raw textarea", {
|
|
error,
|
|
componentStack: info.componentStack,
|
|
});
|
|
this.props.onError(error);
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) return null;
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function readHtmlAttribute(attrs: string, name: string): string | null {
|
|
const match = new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs);
|
|
return match?.[2] ?? match?.[3] ?? match?.[4] ?? null;
|
|
}
|
|
|
|
/** MDXEditor icon override: the image chip's delete button gets the lucide X. */
|
|
function editorIconFor(name: IconKey): ReactElement {
|
|
if (name === "delete_small") return <X aria-hidden />;
|
|
return defaultSvgIcons[name];
|
|
}
|
|
|
|
function convertHtmlImagesToMarkdown(text: string): string {
|
|
return text.replace(/<img\b([^>]*?)\/?>/gi, (tag, attrs: string) => {
|
|
const src = readHtmlAttribute(attrs, "src");
|
|
if (!src) return tag;
|
|
const alt = readHtmlAttribute(attrs, "alt") ?? "image";
|
|
const title = readHtmlAttribute(attrs, "title");
|
|
const escapedAlt = alt.replace(/[[\]]/g, "\\$&");
|
|
const escapedTitle = title?.replace(/"/g, '\\"');
|
|
return escapedTitle
|
|
? ``
|
|
: ``;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert a stored value into the exact markdown handed to MDXEditor.
|
|
*
|
|
* The angle-bracket escape runs last, after the `<img>` rewrite, because that
|
|
* rewrite has to match a bare `<img` tag before the escape hides it.
|
|
*/
|
|
function prepareMarkdownForEditor(value: string): string {
|
|
const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
// Recover escaped blockquotes (`\>`) so `>`-prefixed content renders as a real
|
|
// blockquote in the editor as well as on display (keeps import/export in sync).
|
|
const withBlockquotes = unescapeBlockquoteMarkers(normalizedLineEndings);
|
|
const withImages = convertHtmlImagesToMarkdown(withBlockquotes);
|
|
// The editor runs with `suppressHtmlProcessing`, so a bare `<` that opens an
|
|
// HTML construct would throw and drop the whole component to raw source.
|
|
// Escaping those brackets keeps ordinary prose — `<name>`, `</close>` — in the
|
|
// rich editor. `toStoredMarkdown` reverses it on the way out.
|
|
return escapeUnsupportedAngleBrackets(withImages);
|
|
}
|
|
|
|
/**
|
|
* Convert markdown exported by MDXEditor back into the form this product
|
|
* stores. Inverse of the rewrites `prepareMarkdownForEditor` applies, so the
|
|
* stored value never carries the editor's transport escaping — these fields
|
|
* feed agent prompts and must stay in the clean, human-authored form.
|
|
*/
|
|
function toStoredMarkdown(markdown: string): string {
|
|
return unescapeAngleBracketEscapes(unescapeBlockquoteMarkers(markdown));
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function hasMeaningfulEditorContent(node: Node | null): boolean {
|
|
if (!node) return false;
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return (node.textContent ?? "").trim().length > 0;
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
|
|
const element = node as HTMLElement;
|
|
if (["IMG", "HR", "TABLE", "VIDEO", "IFRAME"].includes(element.tagName)) {
|
|
return true;
|
|
}
|
|
|
|
return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
|
}
|
|
|
|
function hasMarkdownImage(value: string): boolean {
|
|
return /!\[[\s\S]*?\]\([^)]+\)/.test(value);
|
|
}
|
|
|
|
function isRichEditorDomEmpty(
|
|
editable: HTMLElement,
|
|
expectedValue: string,
|
|
placeholder?: string,
|
|
): boolean {
|
|
const expectedText = expectedValue.trim();
|
|
if (!expectedText) return false;
|
|
const expectedHasImage = hasMarkdownImage(expectedText);
|
|
|
|
const visibleText = (editable.textContent ?? "").trim();
|
|
if (visibleText.length === 0) {
|
|
if (expectedHasImage) return false;
|
|
return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
|
}
|
|
|
|
const normalizedPlaceholder = placeholder?.trim();
|
|
if (
|
|
normalizedPlaceholder
|
|
&& visibleText === normalizedPlaceholder
|
|
&& expectedText !== normalizedPlaceholder
|
|
) {
|
|
if (expectedHasImage) return false;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isSafeMarkdownLinkUrl(url: string): boolean {
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return true;
|
|
return !/^(javascript|data|vbscript):/i.test(trimmed);
|
|
}
|
|
|
|
function richEditorErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
if (typeof error === "string") return error;
|
|
return "Rich editor failed to render";
|
|
}
|
|
|
|
/**
|
|
* Why the rich editor was abandoned for this value. Surfaced in the fallback
|
|
* header so a bug report names the path that failed rather than describing the
|
|
* same generic message for three unrelated causes.
|
|
*
|
|
* - `MDE-PARSE` the markdown importer rejected the source
|
|
* - `MDE-RENDER` the editor threw while rendering
|
|
* - `MDE-EMPTY` the editor mounted but never painted the content
|
|
*/
|
|
type RichEditorErrorCode = "MDE-PARSE" | "MDE-RENDER" | "MDE-EMPTY";
|
|
|
|
interface RichEditorError {
|
|
code: RichEditorErrorCode;
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* The editor populates its DOM asynchronously, so "looks empty" is only
|
|
* trustworthy after it has had time to settle and then stayed empty. The first
|
|
* check debounces behind mutations; the second confirms the verdict once.
|
|
*/
|
|
const RICH_EDITOR_EMPTY_CHECK_MS = 100;
|
|
const RICH_EDITOR_EMPTY_CONFIRM_MS = 200;
|
|
|
|
/* ---- Mention detection helpers ---- */
|
|
|
|
interface MentionState {
|
|
trigger: "mention" | "skill";
|
|
marker: "@" | "/";
|
|
query: string;
|
|
top: number;
|
|
left: number;
|
|
/**
|
|
* Caret-aligned viewport coords for portal positioning. `viewportTop` /
|
|
* `viewportBottom` describe the active text line, and `viewportLeft` is the
|
|
* caret X (right edge of the last typed character) so the menu can sit on
|
|
* the same line, just to the right of the cursor.
|
|
*/
|
|
viewportTop: number;
|
|
viewportBottom: number;
|
|
viewportLeft: number;
|
|
textNode: Text;
|
|
atPos: number;
|
|
endPos: number;
|
|
}
|
|
|
|
type AutocompleteOption = MentionOption | SlashCommandOption;
|
|
|
|
interface MentionMenuViewport {
|
|
offsetLeft: number;
|
|
offsetTop: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface MentionMenuSize {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const MENTION_MENU_WIDTH = 188;
|
|
const MENTION_MENU_HEIGHT = 208;
|
|
const MENTION_MENU_PADDING = 8;
|
|
const MENTION_MENU_ROW_HEIGHT = 34;
|
|
const MENTION_MENU_CHROME_HEIGHT = 8;
|
|
const MAX_AUTOCOMPLETE_OPTIONS = 50;
|
|
/** Roughly one space-width of breathing room between the caret and the menu. */
|
|
const MENTION_MENU_CARET_GAP = 10;
|
|
|
|
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
|
txt: "Text",
|
|
md: "Markdown",
|
|
js: "JavaScript",
|
|
jsx: "JavaScript (JSX)",
|
|
ts: "TypeScript",
|
|
tsx: "TypeScript (TSX)",
|
|
json: "JSON",
|
|
bash: "Bash",
|
|
sh: "Shell",
|
|
python: "Python",
|
|
go: "Go",
|
|
rust: "Rust",
|
|
sql: "SQL",
|
|
html: "HTML",
|
|
css: "CSS",
|
|
yaml: "YAML",
|
|
yml: "YAML",
|
|
};
|
|
|
|
const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
|
|
// Keep this lower than codeMirrorPlugin's descriptor priority so known languages
|
|
// still use the standard matching path; this catches malformed/unknown fences.
|
|
priority: 0,
|
|
match: () => true,
|
|
Editor: CodeMirrorEditor,
|
|
};
|
|
|
|
export function findMentionMatch(
|
|
text: string,
|
|
offset: number,
|
|
): Pick<MentionState, "trigger" | "marker" | "query" | "atPos" | "endPos"> | null {
|
|
let atPos = -1;
|
|
let trigger: MentionState["trigger"] | null = null;
|
|
let marker: MentionState["marker"] | null = null;
|
|
for (let i = offset - 1; i >= 0; i--) {
|
|
const ch = text[i];
|
|
if (ch === "@" || ch === "/") {
|
|
if (i === 0 || /\s/.test(text[i - 1])) {
|
|
atPos = i;
|
|
trigger = ch === "@" ? "mention" : "skill";
|
|
marker = ch;
|
|
}
|
|
break;
|
|
}
|
|
if (ch === "\n" || ch === "\r") break;
|
|
}
|
|
|
|
if (atPos === -1) return null;
|
|
const query = text.slice(atPos + 1, offset);
|
|
if (trigger === "skill" && /\s/.test(query) && !query.toLowerCase().startsWith("routine:")) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
trigger: trigger ?? "mention",
|
|
marker: marker ?? "@",
|
|
query,
|
|
atPos,
|
|
endPos: offset,
|
|
};
|
|
}
|
|
|
|
interface CaretRect {
|
|
top: number;
|
|
bottom: number;
|
|
/** Caret X — the right edge of the last typed character (or left edge of the next). */
|
|
x: number;
|
|
}
|
|
|
|
function measureCaretRect(textNode: Text, offset: number, atPos: number): CaretRect {
|
|
const length = textNode.textContent?.length ?? 0;
|
|
const rectFromRange = (start: number, end: number, side: "right" | "left"): CaretRect | null => {
|
|
if (start < 0 || end > length || end <= start) return null;
|
|
const range = document.createRange();
|
|
range.setStart(textNode, start);
|
|
range.setEnd(textNode, end);
|
|
const rect = range.getBoundingClientRect();
|
|
if (rect.width === 0 && rect.height === 0) return null;
|
|
return { top: rect.top, bottom: rect.bottom, x: side === "right" ? rect.right : rect.left };
|
|
};
|
|
|
|
// Prefer the character immediately before the caret — its right edge IS the caret X
|
|
// and its top/bottom describe the active line. Falls back to the char after the caret
|
|
// and finally the @ marker if nothing else gives us a valid rect.
|
|
return (
|
|
rectFromRange(Math.max(0, offset - 1), offset, "right")
|
|
?? rectFromRange(offset, Math.min(length, offset + 1), "left")
|
|
?? rectFromRange(atPos, atPos + 1, "right")
|
|
?? { top: 0, bottom: 0, x: 0 }
|
|
);
|
|
}
|
|
|
|
function detectMention(container: HTMLElement): MentionState | null {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
|
|
|
const range = sel.getRangeAt(0);
|
|
const textNode = range.startContainer;
|
|
if (textNode.nodeType !== Node.TEXT_NODE) return null;
|
|
if (!container.contains(textNode)) return null;
|
|
|
|
const text = textNode.textContent ?? "";
|
|
const offset = range.startOffset;
|
|
const match = findMentionMatch(text, offset);
|
|
if (!match) return null;
|
|
|
|
// Anchor the menu to the live caret so it tracks each typed character instead of
|
|
// staying glued to the @ marker.
|
|
const caret = measureCaretRect(textNode as Text, offset, match.atPos);
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
return {
|
|
trigger: match.trigger,
|
|
marker: match.marker,
|
|
query: match.query,
|
|
top: caret.top - containerRect.top,
|
|
left: caret.x - containerRect.left,
|
|
viewportTop: caret.top,
|
|
viewportBottom: caret.bottom,
|
|
viewportLeft: caret.x,
|
|
textNode: textNode as Text,
|
|
atPos: match.atPos,
|
|
endPos: match.endPos,
|
|
};
|
|
}
|
|
|
|
function getMentionMenuViewport(): MentionMenuViewport {
|
|
const viewport = window.visualViewport;
|
|
if (viewport) {
|
|
return {
|
|
offsetLeft: viewport.offsetLeft,
|
|
offsetTop: viewport.offsetTop,
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
};
|
|
}
|
|
|
|
return {
|
|
offsetLeft: 0,
|
|
offsetTop: 0,
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
};
|
|
}
|
|
|
|
export function computeMentionMenuPosition(
|
|
anchor: Pick<MentionState, "viewportTop" | "viewportBottom" | "viewportLeft">,
|
|
viewport: MentionMenuViewport,
|
|
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
|
|
) {
|
|
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
|
|
const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width;
|
|
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
|
|
const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
|
|
|
|
// Place the menu's top edge on the current line so it sits next to the caret.
|
|
// If it would overflow below, flip above so the menu's bottom hugs the line.
|
|
const desiredTop = viewport.offsetTop + anchor.viewportTop;
|
|
let top: number;
|
|
if (desiredTop > maxTop) {
|
|
const flipped = viewport.offsetTop + anchor.viewportBottom - menuSize.height;
|
|
top = Math.max(minTop, Math.min(flipped, maxTop));
|
|
} else {
|
|
top = Math.max(minTop, desiredTop);
|
|
}
|
|
|
|
// Place the menu's left edge a small gap to the right of the caret X so
|
|
// there's roughly a space-width of breathing room between cursor and menu.
|
|
const desiredLeft = viewport.offsetLeft + anchor.viewportLeft + MENTION_MENU_CARET_GAP;
|
|
const left = Math.max(minLeft, Math.min(desiredLeft, maxLeft));
|
|
|
|
// The menu can grow beyond its estimated width for long task names.
|
|
// Constrain its actual layout to the space remaining beside the caret.
|
|
const maxWidth = Math.max(0, viewport.offsetLeft + viewport.width - MENTION_MENU_PADDING - left);
|
|
return { top, left, maxWidth };
|
|
}
|
|
|
|
function getMentionMenuSize(optionCount: number): MentionMenuSize {
|
|
const visibleRows = Math.max(1, Math.min(optionCount, 8));
|
|
return {
|
|
width: MENTION_MENU_WIDTH,
|
|
height: Math.min(
|
|
MENTION_MENU_HEIGHT,
|
|
visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT,
|
|
),
|
|
};
|
|
}
|
|
|
|
// CodeMirror's editable surface is `.cm-content`, not a `pre` or `code` node.
|
|
// MDXEditor wraps that surface in a hashed `codeMirrorWrapper` class. A paste
|
|
// that starts inside either of those belongs to the nested editor.
|
|
const CODE_LIKE_SELECTOR = [
|
|
"pre",
|
|
"code",
|
|
".cm-editor",
|
|
".cm-content",
|
|
"[class*='codeMirrorWrapper']",
|
|
"[class*='codeBlockEditor']",
|
|
].join(",");
|
|
|
|
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
|
if (!node || !container.contains(node)) return false;
|
|
const el = node instanceof Element ? node : node.parentElement;
|
|
return Boolean(el?.closest(CODE_LIKE_SELECTOR));
|
|
}
|
|
|
|
function isSelectionInsideCodeLikeElement(container: HTMLElement | null) {
|
|
if (!container) return false;
|
|
const selection = window.getSelection();
|
|
if (!selection) return false;
|
|
for (const node of [selection.anchorNode, selection.focusNode]) {
|
|
if (nodeInsideCodeLike(container, node)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isPasteInsideCodeLikeElement(
|
|
container: HTMLElement | null,
|
|
event: { target: EventTarget | null },
|
|
): boolean {
|
|
if (!container) return false;
|
|
if (event.target instanceof Node && nodeInsideCodeLike(container, event.target)) return true;
|
|
const active = typeof document !== "undefined" ? document.activeElement : null;
|
|
if (active instanceof Node && nodeInsideCodeLike(container, active)) return true;
|
|
return isSelectionInsideCodeLikeElement(container);
|
|
}
|
|
|
|
/** The human title of an issue mention — `name` minus its leading identifier. */
|
|
export function issueMentionTitle(option: MentionOption): string {
|
|
const name = option.name.trim();
|
|
const identifier = option.issueIdentifier?.trim();
|
|
if (identifier && name.toLowerCase().startsWith(identifier.toLowerCase())) {
|
|
return name.slice(identifier.length).trim();
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function mentionMarkdown(option: MentionOption): string {
|
|
if (option.kind === "issue" && option.issueIdentifier) {
|
|
// Insert a compact issue link (e.g. `[PAP-123](/issues/PAP-123)`). The chip
|
|
// decorator recognizes this href as an `issue` mention and renders it as a
|
|
// task chip; MarkdownBody linkifies the same href on display.
|
|
return `[${option.issueIdentifier}](${buildIssueReferenceHref(option.issueIdentifier)}) `;
|
|
}
|
|
if (option.kind === "project" && option.projectId) {
|
|
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
|
|
}
|
|
if (option.kind === "user" && option.userId) {
|
|
return `[@${option.name}](${buildUserMentionHref(option.userId)}) `;
|
|
}
|
|
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
|
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
|
|
}
|
|
|
|
function slashCommandLabel(option: SlashCommandOption): string {
|
|
if (option.kind === "routine") return `/routine:${option.name}`;
|
|
if (option.kind === "action") return `/${option.command}`;
|
|
return `/${option.slug}`;
|
|
}
|
|
|
|
function slashCommandMarkdown(option: SlashCommandOption): string {
|
|
// MDXEditor trims an ordinary trailing space when setMarkdown re-imports the
|
|
// command. Keep a non-breaking separator in the document so subsequent text
|
|
// is inserted as the command argument instead of being glued to the token.
|
|
if (option.kind === "action") return `/${option.command}\u00a0`;
|
|
if (option.kind === "routine") {
|
|
return `[${slashCommandLabel(option)}](${buildRoutineMentionHref(option.routineId)}) `;
|
|
}
|
|
return `[/${option.slug}](${option.href}) `;
|
|
}
|
|
|
|
function autocompleteMarkdown(option: AutocompleteOption): string {
|
|
return option.kind === "skill" || option.kind === "routine" || option.kind === "action"
|
|
? slashCommandMarkdown(option)
|
|
: mentionMarkdown(option);
|
|
}
|
|
|
|
export function shouldAcceptAutocompleteKey(
|
|
key: string,
|
|
trigger: MentionState["trigger"] | null,
|
|
skillEnterArmed = false,
|
|
): boolean {
|
|
if (key === "Tab") return true;
|
|
if (key !== "Enter") return false;
|
|
return trigger === "mention" || (trigger === "skill" && skillEnterArmed);
|
|
}
|
|
|
|
export function isSameAutocompleteSession(
|
|
left: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
|
|
right: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
|
|
): boolean {
|
|
if (!left || !right) return false;
|
|
return left.trigger === right.trigger
|
|
&& left.marker === right.marker
|
|
&& left.query === right.query
|
|
&& left.textNode === right.textNode
|
|
&& left.atPos === right.atPos
|
|
&& left.endPos === right.endPos;
|
|
}
|
|
|
|
function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string): boolean {
|
|
if (option.kind === "action") return false;
|
|
const parsed = parseMentionChipHref(href);
|
|
if (!parsed) return false;
|
|
|
|
if (option.kind === "skill") {
|
|
return parsed.kind === "skill" && parsed.skillId === option.skillId;
|
|
}
|
|
if (option.kind === "routine") {
|
|
return parsed.kind === "routine" && parsed.routineId === option.routineId;
|
|
}
|
|
|
|
if (option.kind === "issue" && option.issueIdentifier) {
|
|
return parsed.kind === "issue" && parsed.identifier === option.issueIdentifier;
|
|
}
|
|
if (option.kind === "project" && option.projectId) {
|
|
return parsed.kind === "project" && parsed.projectId === option.projectId;
|
|
}
|
|
if (option.kind === "user" && option.userId) {
|
|
return parsed.kind === "user" && parsed.userId === option.userId;
|
|
}
|
|
|
|
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
|
return parsed.kind === "agent" && parsed.agentId === agentId;
|
|
}
|
|
|
|
export function findClosestAutocompleteAnchor(
|
|
editable: HTMLElement,
|
|
option: AutocompleteOption,
|
|
origin?: Pick<MentionState, "left" | "top"> | null,
|
|
): HTMLAnchorElement | null {
|
|
const matchingMentions = Array.from(editable.querySelectorAll("a"))
|
|
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
|
.filter((link) => autocompleteOptionMatchesLink(option, link.getAttribute("href") ?? ""));
|
|
|
|
if (matchingMentions.length === 0) return null;
|
|
if (!origin) return matchingMentions[0] ?? null;
|
|
|
|
const containerRect = editable.getBoundingClientRect();
|
|
return matchingMentions.sort((a, b) => {
|
|
const rectA = a.getBoundingClientRect();
|
|
const rectB = b.getBoundingClientRect();
|
|
const leftA = rectA.left - containerRect.left;
|
|
const topA = rectA.top - containerRect.top;
|
|
const leftB = rectB.left - containerRect.left;
|
|
const topB = rectB.top - containerRect.top;
|
|
const distA = Math.hypot(leftA - origin.left, topA - origin.top);
|
|
const distB = Math.hypot(leftB - origin.left, topB - origin.top);
|
|
return distA - distB;
|
|
})[0] ?? null;
|
|
}
|
|
|
|
export function placeCaretAfterMentionAnchor(target: HTMLAnchorElement): boolean {
|
|
const selection = window.getSelection();
|
|
if (!selection) return false;
|
|
|
|
const range = document.createRange();
|
|
const nextSibling = target.nextSibling;
|
|
if (nextSibling?.nodeType === Node.TEXT_NODE) {
|
|
const text = nextSibling.textContent ?? "";
|
|
if (text.startsWith(" ")) {
|
|
range.setStart(nextSibling, 1);
|
|
range.collapse(true);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return true;
|
|
}
|
|
if (text.length > 0) {
|
|
range.setStart(nextSibling, 0);
|
|
range.collapse(true);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
range.setStartAfter(target);
|
|
range.collapse(true);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return true;
|
|
}
|
|
|
|
export function placeCaretAtEditableEnd(target: HTMLElement): boolean {
|
|
const selection = window.getSelection();
|
|
if (!selection) return false;
|
|
const range = document.createRange();
|
|
range.selectNodeContents(target);
|
|
range.collapse(false);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return true;
|
|
}
|
|
|
|
/** Replace the active autocomplete token in the markdown string with the selected token. */
|
|
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
|
|
const search = `${state.marker}${state.query}`;
|
|
const replacement = autocompleteMarkdown(option);
|
|
const idx = markdown.lastIndexOf(search);
|
|
if (idx === -1) return markdown;
|
|
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
|
|
}
|
|
|
|
/* ---- Component ---- */
|
|
|
|
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
|
value,
|
|
onChange,
|
|
ariaLabel,
|
|
placeholder,
|
|
className,
|
|
contentClassName,
|
|
onBlur,
|
|
imageUploadHandler,
|
|
onDropFile,
|
|
fileDropTarget = "editor",
|
|
bordered = true,
|
|
mentions,
|
|
actionCommands = [],
|
|
onSubmit,
|
|
readOnly = false,
|
|
}: MarkdownEditorProps, forwardedRef) {
|
|
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
|
|
const { slashCommands: sharedSlashCommands } = useEditorAutocomplete();
|
|
const slashCommands = useMemo(
|
|
() => [...actionCommands, ...sharedSlashCommands],
|
|
[actionCommands, sharedSlashCommands],
|
|
);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const ref = useRef<MDXEditorMethods>(null);
|
|
const fallbackTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const valueRef = useRef(editorValue);
|
|
valueRef.current = editorValue;
|
|
const latestValueRef = useRef(editorValue);
|
|
const initialChildOnChangeRef = useRef(true);
|
|
/**
|
|
* After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange`
|
|
* with the same markdown. Skip notifying the parent for that echo so controlled parents that
|
|
* normalize or transform values cannot loop. Replaces the older blur/focus gate for the same concern.
|
|
*/
|
|
const echoIgnoreMarkdownRef = useRef<string | null>(null);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const [richEditorError, setRichEditorError] = useState<RichEditorError | null>(null);
|
|
const dragDepthRef = useRef(0);
|
|
|
|
// Stable ref for imageUploadHandler so plugins don't recreate on every render
|
|
const imageUploadHandlerRef = useRef(imageUploadHandler);
|
|
imageUploadHandlerRef.current = imageUploadHandler;
|
|
|
|
// Mention state (ref kept in sync so callbacks always see the latest value)
|
|
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
|
const mentionStateRef = useRef<MentionState | null>(null);
|
|
const [mentionIndex, setMentionIndex] = useState(0);
|
|
const autocompleteOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
const skillEnterArmedRef = useRef(false);
|
|
const autocompleteSelectionHandledRef = useRef(false);
|
|
const mentionActive = mentionState !== null && (
|
|
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
|
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
|
);
|
|
const mentionOptionByKey = useMemo(() => {
|
|
const map = new Map<string, MentionOption>();
|
|
for (const mention of mentions ?? []) {
|
|
if (mention.kind === "agent") {
|
|
const agentId = mention.agentId ?? mention.id.replace(/^agent:/, "");
|
|
map.set(`agent:${agentId}`, mention);
|
|
}
|
|
if (mention.kind === "user" && mention.userId) {
|
|
map.set(`user:${mention.userId}`, mention);
|
|
}
|
|
if (mention.kind === "project" && mention.projectId) {
|
|
map.set(`project:${mention.projectId}`, mention);
|
|
}
|
|
}
|
|
return map;
|
|
}, [mentions]);
|
|
|
|
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
|
ref.current = instance;
|
|
if (!instance) {
|
|
return;
|
|
}
|
|
if (valueRef.current !== latestValueRef.current) {
|
|
// Re-apply the latest controlled value once MDXEditor exposes its imperative API.
|
|
echoIgnoreMarkdownRef.current = valueRef.current;
|
|
instance.setMarkdown(valueRef.current);
|
|
latestValueRef.current = valueRef.current;
|
|
}
|
|
}, []);
|
|
|
|
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
|
|
if (!mentionState) return [];
|
|
const q = mentionState.query.trim().toLowerCase();
|
|
if (mentionState.trigger === "skill") {
|
|
return slashCommands
|
|
.filter((command) => {
|
|
if (!q) return true;
|
|
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
|
|
})
|
|
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
|
|
}
|
|
if (!mentions) return [];
|
|
return mentions
|
|
.filter((m) => m.name.toLowerCase().includes(q))
|
|
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
|
|
}, [mentionState, mentions, slashCommands]);
|
|
|
|
const insertMarkdown = useCallback((markdown: string) => {
|
|
if (readOnly) return;
|
|
if (!richEditorError && ref.current) {
|
|
// MDXEditor's insertMarkdown silently no-ops without a Lexical selection
|
|
// (an editor that was never focused). Focus first — the callback runs
|
|
// once focus (and a selection: caret kept, else rootEnd) is in place.
|
|
const editor = ref.current;
|
|
// Inserted markdown reaches the same importer as the mounted document, so
|
|
// it needs the same angle-bracket escaping to survive it.
|
|
const editorMarkdown = escapeUnsupportedAngleBrackets(markdown);
|
|
editor.focus(() => editor.insertMarkdown(editorMarkdown), { defaultSelection: "rootEnd" });
|
|
return;
|
|
}
|
|
const textarea = fallbackTextareaRef.current;
|
|
if (!textarea) {
|
|
onChange(`${value}${markdown}`);
|
|
return;
|
|
}
|
|
const start = textarea.selectionStart ?? value.length;
|
|
const end = textarea.selectionEnd ?? value.length;
|
|
const next = `${value.slice(0, start)}${markdown}${value.slice(end)}`;
|
|
onChange(next);
|
|
requestAnimationFrame(() => {
|
|
textarea.focus();
|
|
const cursor = start + markdown.length;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
});
|
|
}, [onChange, readOnly, richEditorError, value]);
|
|
|
|
useImperativeHandle(forwardedRef, () => ({
|
|
focus: () => {
|
|
if (richEditorError) {
|
|
fallbackTextareaRef.current?.focus();
|
|
return;
|
|
}
|
|
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
|
},
|
|
insertMarkdown,
|
|
clear: () => {
|
|
latestValueRef.current = "";
|
|
echoIgnoreMarkdownRef.current = "";
|
|
ref.current?.setMarkdown("");
|
|
if (fallbackTextareaRef.current) fallbackTextareaRef.current.value = "";
|
|
},
|
|
}), [insertMarkdown, richEditorError]);
|
|
|
|
const autoSizeFallbackTextarea = useCallback((element: HTMLTextAreaElement | null) => {
|
|
if (!element) return;
|
|
element.style.height = "auto";
|
|
element.style.height = `${element.scrollHeight}px`;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!richEditorError) return;
|
|
autoSizeFallbackTextarea(fallbackTextareaRef.current);
|
|
}, [autoSizeFallbackTextarea, richEditorError, value]);
|
|
|
|
useEffect(() => {
|
|
if (richEditorError || editorValue.trim().length === 0) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
let checkTimeoutId = 0;
|
|
let confirmTimeoutId = 0;
|
|
let mutationCount = 0;
|
|
|
|
const looksEmpty = () => {
|
|
const editable = container.querySelector('[contenteditable="true"]');
|
|
if (!(editable instanceof HTMLElement)) return false;
|
|
const activeElement = document.activeElement;
|
|
// A focused editor is the user's, not ours to second-guess.
|
|
if (activeElement === editable || editable.contains(activeElement)) return false;
|
|
return isRichEditorDomEmpty(editable, editorValue, placeholder);
|
|
};
|
|
|
|
// Two phases. Mounting (and especially remounting after "Retry rich
|
|
// editor", where focus sits on the button rather than the editor) leaves
|
|
// the editable momentarily empty, so a single immediate check reads a
|
|
// still-populating editor as a broken one. Wait, then confirm once; any
|
|
// mutation in between means content arrived and restarts the whole thing.
|
|
const scheduleCheck = () => {
|
|
window.clearTimeout(checkTimeoutId);
|
|
window.clearTimeout(confirmTimeoutId);
|
|
checkTimeoutId = window.setTimeout(() => {
|
|
if (!looksEmpty()) return;
|
|
const mutationsAtCheck = mutationCount;
|
|
confirmTimeoutId = window.setTimeout(() => {
|
|
if (mutationCount !== mutationsAtCheck) return;
|
|
if (!looksEmpty()) return;
|
|
setRichEditorError({
|
|
code: "MDE-EMPTY",
|
|
message: "Rich editor failed to load content",
|
|
});
|
|
}, RICH_EDITOR_EMPTY_CONFIRM_MS);
|
|
}, RICH_EDITOR_EMPTY_CHECK_MS);
|
|
};
|
|
|
|
scheduleCheck();
|
|
const observer = new MutationObserver(() => {
|
|
mutationCount += 1;
|
|
scheduleCheck();
|
|
});
|
|
observer.observe(container, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
});
|
|
|
|
return () => {
|
|
window.clearTimeout(checkTimeoutId);
|
|
window.clearTimeout(confirmTimeoutId);
|
|
observer.disconnect();
|
|
};
|
|
}, [editorValue, placeholder, richEditorError]);
|
|
|
|
// Whether the image plugin should be included (boolean is stable across renders
|
|
// as long as the handler presence doesn't toggle)
|
|
const hasImageUpload = Boolean(imageUploadHandler);
|
|
|
|
const plugins = useMemo<RealmPlugin[]>(() => {
|
|
const imageHandler = hasImageUpload
|
|
? async (file: File) => {
|
|
const handler = imageUploadHandlerRef.current;
|
|
if (!handler) throw new Error("No image upload handler");
|
|
try {
|
|
const src = await handler(file);
|
|
setUploadError(null);
|
|
// After MDXEditor inserts the image, ensure two newlines follow it
|
|
// so the cursor isn't stuck right next to the image.
|
|
setTimeout(() => {
|
|
const current = latestValueRef.current;
|
|
const escapedSrc = escapeRegExp(src);
|
|
const updated = current.replace(
|
|
new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"),
|
|
"$1\n\n",
|
|
);
|
|
if (updated !== current) {
|
|
latestValueRef.current = updated;
|
|
echoIgnoreMarkdownRef.current = updated;
|
|
ref.current?.setMarkdown(updated);
|
|
// `updated` derives from `latestValueRef`, which is editor
|
|
// space; the parent only ever sees stored space.
|
|
onChange(toStoredMarkdown(updated));
|
|
requestAnimationFrame(() => {
|
|
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
|
});
|
|
}
|
|
}, 100);
|
|
return src;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Image upload failed";
|
|
setUploadError(message);
|
|
throw err;
|
|
}
|
|
}
|
|
: undefined;
|
|
const all: RealmPlugin[] = [
|
|
headingsPlugin(),
|
|
listsPlugin(),
|
|
quotePlugin(),
|
|
tablePlugin(),
|
|
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
|
|
linkDialogPlugin(),
|
|
mentionDeletionPlugin(),
|
|
pasteNormalizationPlugin(),
|
|
thematicBreakPlugin(),
|
|
codeBlockPlugin({
|
|
defaultCodeBlockLanguage: "txt",
|
|
codeBlockEditorDescriptors: [FALLBACK_CODE_BLOCK_DESCRIPTOR],
|
|
}),
|
|
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
|
|
markdownShortcutPlugin(),
|
|
];
|
|
if (imageHandler) {
|
|
// The inline image chip keeps only its remove affordance — no settings
|
|
// dialog, and the X glyph instead of MDXEditor's default trash can.
|
|
all.push(imagePlugin({ imageUploadHandler: imageHandler, disableImageSettingsButton: true }));
|
|
}
|
|
return all;
|
|
}, [hasImageUpload]);
|
|
|
|
useEffect(() => {
|
|
if (editorValue !== latestValueRef.current) {
|
|
if (ref.current) {
|
|
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
|
|
echoIgnoreMarkdownRef.current = editorValue;
|
|
ref.current.setMarkdown(editorValue);
|
|
latestValueRef.current = editorValue;
|
|
}
|
|
}
|
|
}, [editorValue]);
|
|
|
|
const decorateProjectMentions = useCallback(() => {
|
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
|
if (!editable) return;
|
|
const links = editable.querySelectorAll("a");
|
|
for (const node of links) {
|
|
const link = node as HTMLAnchorElement;
|
|
const parsed = parseMentionChipHref(link.getAttribute("href") ?? "");
|
|
if (!parsed) {
|
|
clearMentionChipDecoration(link);
|
|
continue;
|
|
}
|
|
|
|
if (parsed.kind === "project") {
|
|
const option = mentionOptionByKey.get(`project:${parsed.projectId}`);
|
|
applyMentionChipDecoration(link, {
|
|
...parsed,
|
|
color: parsed.color ?? option?.projectColor ?? null,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (parsed.kind === "skill" || parsed.kind === "routine") {
|
|
applyMentionChipDecoration(link, parsed);
|
|
continue;
|
|
}
|
|
|
|
if (parsed.kind === "user" || parsed.kind === "issue") {
|
|
applyMentionChipDecoration(link, parsed);
|
|
continue;
|
|
}
|
|
|
|
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
|
|
applyMentionChipDecoration(link, {
|
|
...parsed,
|
|
appearance: option?.agentAppearance,
|
|
});
|
|
}
|
|
}, [mentionOptionByKey]);
|
|
|
|
// Mention detection: listen for selection changes and input events
|
|
const checkMention = useCallback(() => {
|
|
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
|
|
mentionStateRef.current = null;
|
|
skillEnterArmedRef.current = false;
|
|
setMentionState(null);
|
|
return;
|
|
}
|
|
const result = detectMention(containerRef.current);
|
|
if (
|
|
result
|
|
&& result.trigger === "mention"
|
|
&& (!mentions || mentions.length === 0)
|
|
) {
|
|
mentionStateRef.current = null;
|
|
skillEnterArmedRef.current = false;
|
|
setMentionState(null);
|
|
return;
|
|
}
|
|
if (
|
|
result
|
|
&& result.trigger === "skill"
|
|
&& slashCommands.length === 0
|
|
) {
|
|
mentionStateRef.current = null;
|
|
skillEnterArmedRef.current = false;
|
|
setMentionState(null);
|
|
return;
|
|
}
|
|
const previous = mentionStateRef.current;
|
|
const sameSession = isSameAutocompleteSession(previous, result);
|
|
mentionStateRef.current = result;
|
|
if (!sameSession) {
|
|
skillEnterArmedRef.current = false;
|
|
setMentionIndex(0);
|
|
}
|
|
setMentionState(result);
|
|
}, [mentions, slashCommands.length]);
|
|
|
|
useEffect(() => {
|
|
if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return;
|
|
|
|
const el = containerRef.current;
|
|
// Listen for input events on the container so mention detection
|
|
// also fires after typing (e.g. space to dismiss).
|
|
const onInput = () => requestAnimationFrame(checkMention);
|
|
|
|
document.addEventListener("selectionchange", checkMention);
|
|
el?.addEventListener("input", onInput, true);
|
|
return () => {
|
|
document.removeEventListener("selectionchange", checkMention);
|
|
el?.removeEventListener("input", onInput, true);
|
|
};
|
|
}, [checkMention, mentions, slashCommands.length]);
|
|
|
|
useEffect(() => {
|
|
if (!mentionActive) return;
|
|
|
|
const updatePosition = () => requestAnimationFrame(checkMention);
|
|
const viewport = window.visualViewport;
|
|
|
|
viewport?.addEventListener("resize", updatePosition);
|
|
viewport?.addEventListener("scroll", updatePosition);
|
|
window.addEventListener("resize", updatePosition);
|
|
window.addEventListener("scroll", updatePosition, true);
|
|
|
|
return () => {
|
|
viewport?.removeEventListener("resize", updatePosition);
|
|
viewport?.removeEventListener("scroll", updatePosition);
|
|
window.removeEventListener("resize", updatePosition);
|
|
window.removeEventListener("scroll", updatePosition, true);
|
|
};
|
|
}, [checkMention, mentionActive]);
|
|
|
|
useEffect(() => {
|
|
if (!mentionActive) return;
|
|
autocompleteOptionRefs.current.length = filteredMentions.length;
|
|
if (mentionIndex >= filteredMentions.length) {
|
|
setMentionIndex(Math.max(0, filteredMentions.length - 1));
|
|
return;
|
|
}
|
|
const activeOption = autocompleteOptionRefs.current[mentionIndex];
|
|
if (!activeOption || typeof activeOption.scrollIntoView !== "function") return;
|
|
activeOption.scrollIntoView({ block: "nearest" });
|
|
}, [filteredMentions.length, mentionActive, mentionIndex]);
|
|
|
|
useEffect(() => {
|
|
if (mentionActive) return;
|
|
autocompleteSelectionHandledRef.current = false;
|
|
}, [mentionActive]);
|
|
|
|
useEffect(() => {
|
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
|
if (!editable) return;
|
|
let frameId: number | null = null;
|
|
let disposed = false;
|
|
const observe = () => {
|
|
observer.observe(editable, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
});
|
|
};
|
|
const flushDecorations = () => {
|
|
frameId = null;
|
|
if (disposed) return;
|
|
observer.disconnect();
|
|
decorateProjectMentions();
|
|
if (!disposed) observe();
|
|
};
|
|
const scheduleDecorations = () => {
|
|
if (frameId !== null) return;
|
|
frameId = requestAnimationFrame(flushDecorations);
|
|
};
|
|
const observer = new MutationObserver(() => {
|
|
scheduleDecorations();
|
|
});
|
|
|
|
flushDecorations();
|
|
return () => {
|
|
disposed = true;
|
|
observer.disconnect();
|
|
if (frameId !== null) {
|
|
cancelAnimationFrame(frameId);
|
|
}
|
|
};
|
|
}, [decorateProjectMentions]);
|
|
|
|
const selectMention = useCallback(
|
|
(option: AutocompleteOption) => {
|
|
// Read from ref to avoid stale-closure issues (selectionchange can
|
|
// update state between the last render and this callback firing).
|
|
const state = mentionStateRef.current;
|
|
if (!state) return false;
|
|
if (option.kind === "action" && option.disabled) return false;
|
|
const current = latestValueRef.current;
|
|
const next = applyMention(current, state, option);
|
|
if (next !== current) {
|
|
latestValueRef.current = next;
|
|
echoIgnoreMarkdownRef.current = next;
|
|
ref.current?.setMarkdown(next);
|
|
// `next` derives from `latestValueRef`, which is editor space; the
|
|
// parent only ever sees stored space.
|
|
onChange(toStoredMarkdown(next));
|
|
}
|
|
|
|
const restoreSelection = (attemptsRemaining: number) => {
|
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
|
if (!(editable instanceof HTMLElement)) return;
|
|
|
|
decorateProjectMentions();
|
|
editable.focus();
|
|
|
|
if (option.kind === "action") {
|
|
placeCaretAtEditableEnd(editable);
|
|
return;
|
|
}
|
|
|
|
const target = findClosestAutocompleteAnchor(editable, option, state);
|
|
if (!target) {
|
|
if (attemptsRemaining > 0) {
|
|
requestAnimationFrame(() => restoreSelection(attemptsRemaining - 1));
|
|
}
|
|
return;
|
|
}
|
|
|
|
placeCaretAfterMentionAnchor(target);
|
|
};
|
|
|
|
requestAnimationFrame(() => restoreSelection(4));
|
|
|
|
mentionStateRef.current = null;
|
|
skillEnterArmedRef.current = false;
|
|
setMentionState(null);
|
|
return true;
|
|
},
|
|
[decorateProjectMentions, onChange],
|
|
);
|
|
|
|
const handleAutocompletePress = useCallback((
|
|
event: ReactMouseEvent<HTMLButtonElement> | ReactPointerEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>,
|
|
option: AutocompleteOption,
|
|
) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (autocompleteSelectionHandledRef.current) return;
|
|
const handled = selectMention(option);
|
|
if (handled) {
|
|
autocompleteSelectionHandledRef.current = true;
|
|
}
|
|
}, [selectMention]);
|
|
|
|
// Touch handling for the mention menu. We deliberately do NOT preventDefault
|
|
// on touchstart so the browser can still scroll the menu vertically; instead
|
|
// we record the start point and only treat the gesture as a selection if the
|
|
// finger lifted with negligible movement (i.e., a tap, not a scroll).
|
|
const touchStartPointRef = useRef<{ x: number; y: number } | null>(null);
|
|
const TOUCH_TAP_THRESHOLD_PX = 8;
|
|
|
|
const handleAutocompleteTouchStart = useCallback((event: ReactTouchEvent<HTMLButtonElement>) => {
|
|
const touch = event.touches[0];
|
|
if (!touch) return;
|
|
touchStartPointRef.current = { x: touch.clientX, y: touch.clientY };
|
|
}, []);
|
|
|
|
const handleAutocompleteTouchMove = useCallback((event: ReactTouchEvent<HTMLButtonElement>) => {
|
|
const start = touchStartPointRef.current;
|
|
if (!start) return;
|
|
const touch = event.touches[0];
|
|
if (!touch) return;
|
|
if (Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TOUCH_TAP_THRESHOLD_PX) {
|
|
touchStartPointRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const handleAutocompleteTouchEnd = useCallback((
|
|
event: ReactTouchEvent<HTMLButtonElement>,
|
|
option: AutocompleteOption,
|
|
) => {
|
|
const start = touchStartPointRef.current;
|
|
touchStartPointRef.current = null;
|
|
if (!start) return;
|
|
const touch = event.changedTouches[0];
|
|
if (!touch) return;
|
|
if (Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TOUCH_TAP_THRESHOLD_PX) {
|
|
return;
|
|
}
|
|
handleAutocompletePress(event, option);
|
|
}, [handleAutocompletePress]);
|
|
|
|
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
|
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
|
}
|
|
|
|
const canDropFile = fileDropTarget === "editor" && Boolean(imageUploadHandler || onDropFile);
|
|
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
|
const clipboard = event.clipboardData;
|
|
if (!clipboard || !ref.current) return;
|
|
const types = new Set(Array.from(clipboard.types));
|
|
if (types.has("Files") || types.has("text/html")) return;
|
|
if (isPasteInsideCodeLikeElement(containerRef.current, event)) return;
|
|
|
|
const rawText = clipboard.getData("text/plain");
|
|
if (!looksLikeMarkdownPaste(rawText)) return;
|
|
|
|
event.preventDefault();
|
|
// Lexical also handles paste on the editable element. Once Markdown is
|
|
// inserted here, prevent that handler from inserting the plain text again.
|
|
event.stopPropagation();
|
|
ref.current.insertMarkdown(escapeUnsupportedAngleBrackets(normalizeMarkdown(rawText)));
|
|
}, []);
|
|
|
|
const handleRichEditorRenderError = useCallback((error: unknown) => {
|
|
setRichEditorError({ code: "MDE-RENDER", message: richEditorErrorMessage(error) });
|
|
}, []);
|
|
|
|
const handleRichEditorParseError = useCallback((error: unknown) => {
|
|
setRichEditorError({ code: "MDE-PARSE", message: richEditorErrorMessage(error) });
|
|
}, []);
|
|
|
|
const mentionMenuPosition = mentionState
|
|
? computeMentionMenuPosition(
|
|
mentionState,
|
|
getMentionMenuViewport(),
|
|
getMentionMenuSize(filteredMentions.length),
|
|
)
|
|
: null;
|
|
|
|
if (richEditorError) {
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"relative paperclip-mdxeditor-scope",
|
|
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-3 pt-2 text-xs text-muted-foreground">
|
|
<p>
|
|
Rich editor unavailable for this markdown. Showing raw source instead.{" "}
|
|
<span data-testid="markdown-editor-fallback-code" className="font-mono">
|
|
{richEditorError.code}
|
|
</span>
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 underline underline-offset-2 hover:text-foreground"
|
|
onClick={() => {
|
|
// The retry remounts MDXEditor, so re-arm the mount-time guard:
|
|
// a fresh mount can emit an empty onChange that would otherwise
|
|
// wipe the parent's value.
|
|
initialChildOnChangeRef.current = true;
|
|
setRichEditorError(null);
|
|
}}
|
|
>
|
|
Retry rich editor
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
ref={fallbackTextareaRef}
|
|
aria-label={ariaLabel}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
readOnly={readOnly}
|
|
onChange={(event) => {
|
|
if (readOnly) return;
|
|
onChange(event.target.value);
|
|
autoSizeFallbackTextarea(event.target);
|
|
}}
|
|
onBlur={() => onBlur?.()}
|
|
onKeyDown={(event) => {
|
|
if (onSubmit && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
onSubmit();
|
|
}
|
|
}}
|
|
className={cn(
|
|
"min-h-(--sz-12rem) w-full resize-none bg-transparent px-3 pb-3 pt-2 font-mono text-sm leading-6 outline-none",
|
|
contentClassName,
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"relative paperclip-mdxeditor-scope",
|
|
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
|
|
isDragOver && "ring-1 ring-primary/60 bg-accent/20",
|
|
className,
|
|
)}
|
|
onInputCapture={(event) => {
|
|
if (
|
|
!readOnly
|
|
&& event.target instanceof HTMLElement
|
|
&& event.target.closest('[contenteditable="true"]')
|
|
) {
|
|
// Actual input may be the editor's first change. An intentional clear
|
|
// must not be mistaken for its programmatic empty mount reset.
|
|
initialChildOnChangeRef.current = false;
|
|
}
|
|
}}
|
|
onKeyDownCapture={(e) => {
|
|
if (readOnly) return;
|
|
if (
|
|
(e.key === "Backspace" || e.key === "Delete")
|
|
&& e.target instanceof HTMLElement
|
|
&& e.target.closest('[contenteditable="true"]')
|
|
) {
|
|
// Lexical handles deletion on keydown and may suppress DOM input.
|
|
initialChildOnChangeRef.current = false;
|
|
}
|
|
// Cmd/Ctrl+Enter to submit
|
|
if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onSubmit();
|
|
return;
|
|
}
|
|
|
|
// Mention keyboard handling
|
|
if (mentionActive) {
|
|
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
|
|
mentionStateRef.current = null;
|
|
skillEnterArmedRef.current = false;
|
|
setMentionState(null);
|
|
return;
|
|
}
|
|
// Escape always dismisses
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
mentionStateRef.current = null;
|
|
skillEnterArmedRef.current = false;
|
|
setMentionState(null);
|
|
return;
|
|
}
|
|
// Arrow / Enter / Tab only when there are filtered results
|
|
if (filteredMentions.length > 0) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
|
|
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
|
|
setMentionIndex((prev) => Math.max(prev - 1, 0));
|
|
return;
|
|
}
|
|
if (
|
|
shouldAcceptAutocompleteKey(
|
|
e.key,
|
|
mentionStateRef.current?.trigger ?? null,
|
|
skillEnterArmedRef.current,
|
|
)
|
|
) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
selectMention(filteredMentions[mentionIndex]);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
onDragEnter={(evt) => {
|
|
if (readOnly) return;
|
|
if (!canDropFile || !hasFilePayload(evt)) return;
|
|
dragDepthRef.current += 1;
|
|
setIsDragOver(true);
|
|
}}
|
|
onDragOver={(evt) => {
|
|
if (readOnly) return;
|
|
if (!canDropFile || !hasFilePayload(evt)) return;
|
|
evt.preventDefault();
|
|
evt.dataTransfer.dropEffect = "copy";
|
|
}}
|
|
onDragLeave={() => {
|
|
if (readOnly) return;
|
|
if (!canDropFile) return;
|
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
if (dragDepthRef.current === 0) setIsDragOver(false);
|
|
}}
|
|
onDrop={(evt) => {
|
|
if (readOnly) return;
|
|
dragDepthRef.current = 0;
|
|
setIsDragOver(false);
|
|
if (!onDropFile) return;
|
|
const files = evt.dataTransfer?.files;
|
|
if (!files || files.length === 0) return;
|
|
const allFiles = Array.from(files);
|
|
const nonImageFiles = allFiles.filter(
|
|
(f) => !f.type.startsWith("image/"),
|
|
);
|
|
if (nonImageFiles.length === 0) return;
|
|
// If all dropped files are non-image, prevent default so MDXEditor
|
|
// doesn't try to handle them. If mixed, let images flow through to
|
|
// the image plugin and only handle the non-image files ourselves.
|
|
if (nonImageFiles.length === allFiles.length) {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
}
|
|
for (const file of nonImageFiles) {
|
|
void onDropFile(file);
|
|
}
|
|
}}
|
|
onPasteCapture={handlePasteCapture}
|
|
>
|
|
<MarkdownEditorRichErrorBoundary onError={handleRichEditorRenderError}>
|
|
<MDXEditor
|
|
ref={setEditorRef}
|
|
translation={(key, fallback, interpolations = {}) =>
|
|
key === "contentArea.editableMarkdown" && ariaLabel
|
|
? ariaLabel
|
|
: Object.entries(interpolations).reduce(
|
|
(text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)),
|
|
fallback,
|
|
)
|
|
}
|
|
markdown={editorValue}
|
|
iconComponentFor={editorIconFor}
|
|
suppressHtmlProcessing
|
|
placeholder={placeholder}
|
|
readOnly={readOnly}
|
|
onChange={(rawNext, initialMarkdownNormalize) => {
|
|
if (readOnly) return;
|
|
// Reverse the editor-only rewrites: blockquotes the exporter escaped
|
|
// as `\>` (so a `>`-prefixed line the user typed survives even when
|
|
// the WYSIWYG shortcut didn't fire), and the `\<` transport escaping
|
|
// that keeps bare angle brackets off the HTML parser.
|
|
const next = toStoredMarkdown(rawNext);
|
|
const echo = echoIgnoreMarkdownRef.current;
|
|
if (echo !== null) {
|
|
echoIgnoreMarkdownRef.current = null;
|
|
// `echo` is what we handed to `setMarkdown`, so it is in editor
|
|
// space while `next` is in stored space. Accept either form —
|
|
// otherwise every prop sync of a value containing an escaped
|
|
// bracket reads as a real edit and notifies the parent.
|
|
if (next === echo || next === toStoredMarkdown(echo)) {
|
|
latestValueRef.current = echo;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (initialChildOnChangeRef.current) {
|
|
initialChildOnChangeRef.current = false;
|
|
if (next === "" && editorValue !== "") {
|
|
echoIgnoreMarkdownRef.current = editorValue;
|
|
ref.current?.setMarkdown(editorValue);
|
|
return;
|
|
}
|
|
}
|
|
// MDXEditor re-serialises the initial markdown on load (bullet
|
|
// markers, escapes, blank lines) and flags that emission. It is not
|
|
// a user edit: forwarding it marks every controlled parent with
|
|
// dirty tracking as unsaved before any keystroke. `latestValueRef`
|
|
// stays on the parent's value, so the prop-sync effect does not
|
|
// push the original back over the editor.
|
|
if (initialMarkdownNormalize) return;
|
|
// `latestValueRef` is compared against `editorValue`, so it has to
|
|
// hold editor space; storing `next` would make every edit containing
|
|
// an escaped bracket look like a pending prop sync and trigger a
|
|
// redundant `setMarkdown` that resets the caret.
|
|
latestValueRef.current = prepareMarkdownForEditor(next);
|
|
onChange(next);
|
|
}}
|
|
onBlur={() => onBlur?.()}
|
|
onError={(payload) => {
|
|
handleRichEditorParseError(payload.error);
|
|
}}
|
|
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
|
|
contentEditableClassName={cn(
|
|
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
|
contentClassName,
|
|
)}
|
|
additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]}
|
|
plugins={plugins}
|
|
/>
|
|
</MarkdownEditorRichErrorBoundary>
|
|
|
|
{/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
|
|
{mentionActive && filteredMentions.length > 0 && mentionMenuPosition &&
|
|
createPortal(
|
|
<div
|
|
data-paperclip-floating-ui=""
|
|
data-testid="mention-autocomplete-menu"
|
|
className="pointer-events-auto fixed z-(--z-9999) min-w-(--sz-180px) max-w-(--sz-calc-15) max-h-(--sz-208px) overflow-y-auto overscroll-contain rounded-md border border-border bg-popover shadow-md"
|
|
style={{
|
|
top: mentionMenuPosition.top,
|
|
left: mentionMenuPosition.left,
|
|
maxWidth: mentionMenuPosition.maxWidth,
|
|
touchAction: "pan-y",
|
|
WebkitOverflowScrolling: "touch",
|
|
}}
|
|
onWheelCapture={(event) => {
|
|
// Modal scroll locks treat this body-level portal as outside the
|
|
// dialog. Keep wheel input on the menu so the lock cannot cancel it.
|
|
event.stopPropagation();
|
|
}}
|
|
onTouchMove={(event) => {
|
|
// Let the touched option observe movement first, then keep the
|
|
// native event from reaching a modal's document-level scroll lock.
|
|
event.stopPropagation();
|
|
}}
|
|
>
|
|
{filteredMentions.map((option, i) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
tabIndex={-1}
|
|
disabled={option.kind === "action" && option.disabled === true}
|
|
aria-disabled={option.kind === "action" && option.disabled === true}
|
|
title={option.kind === "action" && option.disabled ? option.disabledReason ?? undefined : undefined}
|
|
ref={(node) => {
|
|
autocompleteOptionRefs.current[i] = node;
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
|
i === mentionIndex && "bg-accent",
|
|
option.kind === "action" && option.disabled && "cursor-not-allowed opacity-60 hover:bg-transparent",
|
|
)}
|
|
onPointerDown={(e) => {
|
|
// Touch is handled via onTouchStart/onTouchEnd so vertical scrolling
|
|
// isn't swallowed; only handle mouse/pen here.
|
|
if (e.pointerType === "touch") return;
|
|
handleAutocompletePress(e, option);
|
|
}}
|
|
onMouseDown={(e) => handleAutocompletePress(e, option)}
|
|
onTouchStart={handleAutocompleteTouchStart}
|
|
onTouchMove={handleAutocompleteTouchMove}
|
|
onTouchEnd={(e) => handleAutocompleteTouchEnd(e, option)}
|
|
onMouseEnter={() => {
|
|
if (mentionStateRef.current?.trigger === "skill") {
|
|
skillEnterArmedRef.current = true;
|
|
}
|
|
setMentionIndex(i);
|
|
}}
|
|
>
|
|
{option.kind === "routine" ? (
|
|
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : option.kind === "action" ? (
|
|
<Flag className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : option.kind === "skill" ? (
|
|
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : option.kind === "issue" ? (
|
|
<Hash className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : option.kind === "project" && option.projectId ? (
|
|
<span
|
|
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
|
style={{ backgroundColor: option.projectColor ?? "var(--project-none)" }}
|
|
/>
|
|
) : option.kind === "user" ? (
|
|
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<AgentAvatar agent={{ id: option.agentId ?? option.id, name: option.name, appearance: option.agentAppearance }} size={16} />
|
|
)}
|
|
{option.kind === "issue" && option.issueIdentifier ? (
|
|
<span className="flex min-w-0 items-baseline gap-1.5">
|
|
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
|
|
{option.issueIdentifier}
|
|
</span>
|
|
<span className="truncate">{issueMentionTitle(option)}</span>
|
|
</span>
|
|
) : (
|
|
<span className="truncate">
|
|
{option.kind === "skill" || option.kind === "routine" || option.kind === "action"
|
|
? slashCommandLabel(option)
|
|
: option.name}
|
|
</span>
|
|
)}
|
|
{option.kind === "issue" && (
|
|
<span className="ml-auto text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
|
|
Task
|
|
</span>
|
|
)}
|
|
{option.kind === "project" && option.projectId && (
|
|
<span className="ml-auto text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
|
|
Project
|
|
</span>
|
|
)}
|
|
{option.kind === "user" && (
|
|
<span className="ml-auto text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
|
|
User
|
|
</span>
|
|
)}
|
|
{option.kind === "skill" && (
|
|
<span className="ml-auto text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
|
|
Skill
|
|
</span>
|
|
)}
|
|
{option.kind === "routine" && (
|
|
<span className="ml-auto text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
|
|
Routine
|
|
</span>
|
|
)}
|
|
{option.kind === "action" && (
|
|
<span className="ml-auto max-w-28 truncate text-(length:--text-nano) text-muted-foreground">
|
|
{option.disabled ? option.disabledReason : option.description}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
|
|
{isDragOver && canDropFile && (
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
|
|
!bordered && "inset-0 rounded-sm",
|
|
)}
|
|
>
|
|
Drop {onDropFile ? "file" : "image"} to upload
|
|
</div>
|
|
)}
|
|
{uploadError && (
|
|
<p className="px-3 pb-2 text-xs text-destructive">{uploadError}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|