mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The first-run experience (onboarding wizard) and the chat surfaces
(conference-room/board chat, task threads, composers) are the product's
front door — they decide whether a new operator understands "hire
agents, give them work, review results" in the first five minutes
> - Today those surfaces feel ticket-y and form-like: the wizard is a
static multi-step form that ends in an anticlimactic "Launch" screen,
the task composer and board chat behave differently from each other, and
agent-feed issue quicklooks misbehave (multiple flyouts open at once,
cards jump on hover)
> - We wanted to iterate toward a conversational, team-centric NUX — but
without risking the workflows of everyone already running Paperclip
> - This PR reworks the NUX behind a new default-OFF
`enableConferenceRoomChat` experimental flag: a capsule-motif onboarding
wizard that builds your team as you answer, a conference-room chat
surface, one shared ChatComposer across surfaces, brand-accurate status
chips, and feed-quicklook fixes — with the pre-existing UI
fork-and-frozen as `*Classic` components that flag-OFF users keep
> - The benefit is a complete, testable modern NUX that anyone can opt
into from Settings → Experimental, with zero default behavior change and
a clean path to either graduate or drop the experiment
## Linked Issues or Issue Description
No pre-existing GitHub issue — feature description per
`feature_request.yml`:
- **Problem / motivation:** Paperclip's onboarding wizard and chat
surfaces grew up as separate ticket-centric forms. New users get a
form-filling experience rather than the feeling of standing up a team;
the board chat and task threads use different composers with different
affordances; the agent feed's issue quicklook can stack multiple
popovers and shifts cards on hover.
- **Proposed solution:** A coherent NUX experiment behind one
experimental flag (`enableConferenceRoomChat`, Settings → Experimental,
default OFF): capsule onboarding wizard with an evolving team capsule,
conference-room chat, unified `ChatComposer`, team-centric copy, brand
status chips, quicklook single-flight fix. Flag-OFF users get the exact
pre-experiment UI via frozen `*Classic` forks, verified by an on/off
parity test matrix.
- **Alternatives considered:** (a) incremental unflagged restyling —
rejected: the changes interlock across surfaces and would drip risk into
every release; (b) a separate app shell / route for the new NUX —
rejected: too much divergence, the flag + classic-fork pattern keeps the
diff reviewable and reversible.
- **Roadmap alignment:** `ROADMAP.md` lists **CEO Chat** ("a
lighter-weight way to talk to leadership agents... should still resolve
to real work objects"). This experiment is groundwork in that direction
(conference-room chat resolves to issues/tasks via the same composer
used in task threads) and does not change the core task-and-comments
model.
Related PRs found in the dedup search (same area, none duplicate this
work — they target the classic wizard, which this PR intentionally
leaves intact and mergeable):
- #5385 — Coach-driven onboarding: conversational entry +
agent-companies package import
- #5378 — Onboarding wizard: reusable adapter picker + probe card
- #6636 — ui(onboarding): friendly error surface + retry for the wizard
- #7005 — fix(onboarding): explicitly await first-task wake
- #2616 — fix: restore workspace directory config in onboarding wizard
## What Changed
- **Experimental flag plumbing** — `enableConferenceRoomChat` in shared
types/validators, server instance-settings service + API, Settings →
Experimental card with explicit enable/disable copy
- **Onboarding wizard** — classic wizard forked and frozen
(`OnboardingWizardClassic`); flag-ON variant is a 5-step capsule wizard
with a persistent evolving `AgentCapsule` (gradient/glow motif),
team-centric reframed copy, and a typing-dots intro (hardened with
fake-timer tests)
- **Conference-room chat** — flag-ON board-chat surface with agent
bubble name/icon headers and copy/vote/timestamp action rows
(`AgentBubbleActionRow`)
- **Unified composer** — shared `ChatComposer` adopted across surfaces;
translucent surface + scroll-mask removal; "Agent mode"/"Plan mode"
relabels; no-assignee confirmation `AlertDialog` (new
`ui/alert-dialog.tsx` primitive); `@task` reference picker +
linkification in mentions
- **Agent feed** — single-flight issue-quicklook store (one popover at a
time), flyouts open to the left, removed hover translate-y jitter
- **Status chips** — brand-accurate task status chips behind the flag
(light/dark, 1px borders per paperclip.ing/brand)
- **Tests** — flag on/off parity matrix across IssueDetail,
NewIssueDialog, Sidebar, wizard, gate components; component tests for
all new pieces
- **Merge with `master`** — one conflict in
`ui/src/components/IssueChatThread.tsx`, resolved by keeping master's
new `AssigneeChip`/`HandoffWakeRow`/`RunStatusBadge` components inside
the flag-gated metadata-row chrome (details in commit `21a5642a`);
post-merge fixes: vitest 4 mock typing in `MarkdownEditor.test.tsx`,
flag hook made safe for provider-less mounts (master's new isolated
component tests)
- **Branch hygiene** — internal design wireframes/mockups stripped
before the PR (they live in the Paperclip issue threads)
- No user-facing documentation changes required: the flag is
intentionally experimental and self-described in the Settings card; no
existing docs reference the affected surfaces
## Verification
- `pnpm run typecheck` — green across the workspace (ui, server, shared,
plugins)
- Full UI suite (`vitest run` in `ui/`, clean worktree at this HEAD):
**1593/1595 passing, 223/224 files** — the 2 remaining failures are in
`src/components/artifacts/ArtifactCard.test.tsx` and **fail identically
on pristine `origin/master`** (pre-existing upstream, unrelated to this
branch)
- Full server suite (`vitest run` in `server/`, same clean worktree):
results in PR checks; flag plumbing covered by instance-settings tests
- Targeted post-merge resolution check: `IssueChatThread`,
`IssueChatThreadSystemNotice`, `IssueDetail`, `Sidebar`,
`ConferenceRoomChatGate`, `OnboardingWizardVariant`, `NewIssueDialog`,
`InstanceExperimentalSettings`, `MarkdownEditor` — 172/172 passing
- Manual walkthrough: flag OFF (default) → onboarding wizard, task
thread, board chat, composer all render the classic UI; flag ON via
Settings → Experimental → capsule wizard, conference-room chat, unified
composer, status chips active
- Screenshots: see below
**Flag on/off screenshots** (committed on this branch under
`screenshots/PR-8000-*`):
| Surface | Flag OFF (classic, default) | Flag ON (experimental) |
| --- | --- | --- |
| Settings → Experimental | 
| 
|
| Task thread | 
| 
|
| Home / nav | 
| 
|
| Conference Room (flag-ON only surface) | — | 
|
Capsule onboarding wizard walkthrough screenshots (flag ON) are attached
to the Paperclip design/implementation threads; the wizard requires a
fresh instance so it is captured via the e2e harness
(`tests/e2e/nux-phase4-screenshots.spec.ts`).
## Risks
- **Large surface, but gated:** all new behavior sits behind
`enableConferenceRoomChat`, default OFF; flag-OFF rendering is locked by
frozen `*Classic` forks plus an on/off parity test suite
- **Classic forks are frozen at the fork point (`e3aada1d`):** master
features added to the live thread component after that point (assignee
handoff chips, run status badge, composer mention coach) render in the
flag-ON path; the flag-OFF task thread keeps the fork-point behavior
until the experiment graduates (forks deleted) or is dropped (forks
restored as canonical). Called out for reviewer attention.
- **Merge-conflict resolution in `IssueChatThread.tsx`** (commit
`21a5642a`) deserves reviewer eyes: master's new handoff/run-status
components were kept; the base toast-style no-assignee flow remains
replaced by the AlertDialog flow introduced on this branch
- Schema/server changes are additive (one optional boolean instance
setting); no migrations of existing data
## Model Used
- Claude (Anthropic) via Claude Code running in the Paperclip agent
harness (agent: ClaudeCoder)
- Branch implemented across multiple agent sessions on Claude Opus-class
models with extended thinking + tool use (file edits, shell, Playwright
screenshots); merge/PR session model ID as reported by the harness:
`claude-fable-5` (Claude Code CLI)
- All code was agent-authored and board-reviewed through Paperclip issue
threads (plans, wireframes, confirmations) before merging
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes (none
required — experimental flag, self-documenting Settings card; noted
above)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (run 3 on `8af3041a`: all 16
gates SUCCESS, incl. e2e and all 4 serialized-suite shards)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(re-review verdict: Confidence 5/5, “Safe to merge”; all 4 round-1
findings fixed + confirmed resolved; both summary notes addressed in
`8af3041a`)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1402 lines
48 KiB
TypeScript
1402 lines
48 KiB
TypeScript
import {
|
|
type ClipboardEvent,
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type DragEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
type TouchEvent as ReactTouchEvent,
|
|
} 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,
|
|
type RealmPlugin,
|
|
} from "@mdxeditor/editor";
|
|
import {
|
|
buildAgentMentionHref,
|
|
buildIssueReferenceHref,
|
|
buildProjectMentionHref,
|
|
buildRoutineMentionHref,
|
|
buildUserMentionHref,
|
|
} from "@paperclipai/shared";
|
|
import { Boxes, CalendarClock, Hash, User } from "lucide-react";
|
|
import { AgentIcon } from "./AgentIconPicker";
|
|
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 { 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;
|
|
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;
|
|
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[];
|
|
/** Called on Cmd/Ctrl+Enter */
|
|
onSubmit?: () => void;
|
|
/** Render the rich editor without allowing edits. */
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export interface MarkdownEditorRef {
|
|
focus: () => void;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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
|
|
? ``
|
|
: ``;
|
|
});
|
|
}
|
|
|
|
function prepareMarkdownForEditor(value: string): string {
|
|
const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
return convertHtmlImagesToMarkdown(normalizedLineEndings);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/* ---- 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));
|
|
|
|
return { top, left };
|
|
}
|
|
|
|
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,
|
|
),
|
|
};
|
|
}
|
|
|
|
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
|
if (!node || !container.contains(node)) return false;
|
|
const el = node.nodeType === Node.ELEMENT_NODE
|
|
? (node as HTMLElement)
|
|
: node.parentElement;
|
|
return Boolean(el?.closest("pre, code"));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** 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 {
|
|
return option.kind === "routine" ? `/routine:${option.name}` : `/${option.slug}`;
|
|
}
|
|
|
|
function slashCommandMarkdown(option: SlashCommandOption): string {
|
|
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"
|
|
? 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 {
|
|
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;
|
|
}
|
|
|
|
/** 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,
|
|
placeholder,
|
|
className,
|
|
contentClassName,
|
|
onBlur,
|
|
imageUploadHandler,
|
|
onDropFile,
|
|
fileDropTarget = "editor",
|
|
bordered = true,
|
|
mentions,
|
|
onSubmit,
|
|
readOnly = false,
|
|
}: MarkdownEditorProps, forwardedRef) {
|
|
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
|
|
const { slashCommands } = useEditorAutocomplete();
|
|
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<string | 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]);
|
|
|
|
useImperativeHandle(forwardedRef, () => ({
|
|
focus: () => {
|
|
if (richEditorError) {
|
|
fallbackTextareaRef.current?.focus();
|
|
return;
|
|
}
|
|
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
|
},
|
|
}), [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 timeoutId = 0;
|
|
const scheduleCheck = () => {
|
|
window.clearTimeout(timeoutId);
|
|
timeoutId = window.setTimeout(() => {
|
|
const editable = container.querySelector('[contenteditable="true"]');
|
|
if (!(editable instanceof HTMLElement)) return;
|
|
const activeElement = document.activeElement;
|
|
if (activeElement === editable || editable.contains(activeElement)) return;
|
|
if (isRichEditorDomEmpty(editable, editorValue, placeholder)) {
|
|
setRichEditorError("Rich editor failed to load content");
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
scheduleCheck();
|
|
const observer = new MutationObserver(() => {
|
|
scheduleCheck();
|
|
});
|
|
observer.observe(container, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
});
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
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);
|
|
onChange(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) {
|
|
all.push(imagePlugin({ imageUploadHandler: imageHandler }));
|
|
}
|
|
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,
|
|
icon: parsed.icon ?? option?.agentIcon ?? null,
|
|
});
|
|
}
|
|
}, [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;
|
|
decorateProjectMentions();
|
|
const observer = new MutationObserver(() => {
|
|
decorateProjectMentions();
|
|
});
|
|
observer.observe(editable, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
});
|
|
return () => observer.disconnect();
|
|
}, [decorateProjectMentions, value]);
|
|
|
|
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;
|
|
const current = latestValueRef.current;
|
|
const next = applyMention(current, state, option);
|
|
if (next !== current) {
|
|
latestValueRef.current = next;
|
|
echoIgnoreMarkdownRef.current = next;
|
|
ref.current?.setMarkdown(next);
|
|
onChange(next);
|
|
}
|
|
|
|
const restoreSelection = (attemptsRemaining: number) => {
|
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
|
if (!(editable instanceof HTMLElement)) return;
|
|
|
|
decorateProjectMentions();
|
|
editable.focus();
|
|
|
|
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 (isSelectionInsideCodeLikeElement(containerRef.current)) return;
|
|
|
|
const rawText = clipboard.getData("text/plain");
|
|
if (!looksLikeMarkdownPaste(rawText)) return;
|
|
|
|
event.preventDefault();
|
|
ref.current.insertMarkdown(normalizeMarkdown(rawText));
|
|
}, []);
|
|
|
|
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.</p>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 underline underline-offset-2 hover:text-foreground"
|
|
onClick={() => {
|
|
setRichEditorError(null);
|
|
}}
|
|
>
|
|
Retry rich editor
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
ref={fallbackTextareaRef}
|
|
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-[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,
|
|
)}
|
|
onKeyDownCapture={(e) => {
|
|
if (readOnly) return;
|
|
// 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}
|
|
>
|
|
<MDXEditor
|
|
ref={setEditorRef}
|
|
markdown={editorValue}
|
|
suppressHtmlProcessing
|
|
placeholder={placeholder}
|
|
readOnly={readOnly}
|
|
onChange={(next) => {
|
|
if (readOnly) return;
|
|
const echo = echoIgnoreMarkdownRef.current;
|
|
if (echo !== null && next === echo) {
|
|
echoIgnoreMarkdownRef.current = null;
|
|
latestValueRef.current = next;
|
|
return;
|
|
}
|
|
if (echo !== null) {
|
|
echoIgnoreMarkdownRef.current = null;
|
|
}
|
|
|
|
if (initialChildOnChangeRef.current) {
|
|
initialChildOnChangeRef.current = false;
|
|
if (next === "" && editorValue !== "") {
|
|
echoIgnoreMarkdownRef.current = editorValue;
|
|
ref.current?.setMarkdown(editorValue);
|
|
return;
|
|
}
|
|
}
|
|
latestValueRef.current = next;
|
|
onChange(next);
|
|
}}
|
|
onBlur={() => onBlur?.()}
|
|
onError={(payload) => {
|
|
setRichEditorError(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}
|
|
/>
|
|
|
|
{/* 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-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[208px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
|
style={{
|
|
top: mentionMenuPosition.top,
|
|
left: mentionMenuPosition.left,
|
|
touchAction: "pan-y",
|
|
WebkitOverflowScrolling: "touch",
|
|
}}
|
|
>
|
|
{filteredMentions.map((option, i) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
tabIndex={-1}
|
|
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",
|
|
)}
|
|
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 === "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 ?? "#64748b" }}
|
|
/>
|
|
) : option.kind === "user" ? (
|
|
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<AgentIcon
|
|
icon={option.agentIcon}
|
|
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
|
/>
|
|
)}
|
|
{option.kind === "issue" && option.issueIdentifier ? (
|
|
<span className="flex min-w-0 items-baseline gap-1.5">
|
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
|
{option.issueIdentifier}
|
|
</span>
|
|
<span className="truncate">{issueMentionTitle(option)}</span>
|
|
</span>
|
|
) : (
|
|
<span className="truncate">
|
|
{option.kind === "skill" || option.kind === "routine"
|
|
? slashCommandLabel(option)
|
|
: option.name}
|
|
</span>
|
|
)}
|
|
{option.kind === "issue" && (
|
|
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
Task
|
|
</span>
|
|
)}
|
|
{option.kind === "project" && option.projectId && (
|
|
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
Project
|
|
</span>
|
|
)}
|
|
{option.kind === "user" && (
|
|
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
User
|
|
</span>
|
|
)}
|
|
{option.kind === "skill" && (
|
|
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
Skill
|
|
</span>
|
|
)}
|
|
{option.kind === "routine" && (
|
|
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
Routine
|
|
</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>
|
|
);
|
|
});
|