feat: use markdown editor with @-mentions for issue comments
- Replaced plain textarea in CommentThread with MarkdownEditor for rich markdown editing (no toolbar, compact styling) - Added @-mention autocomplete to MarkdownEditor: - Detects @ trigger while typing with cursor-positioned dropdown - Arrow key navigation, Enter/Tab to select, Escape to dismiss - Filters mentionable names as user types after @ - Added onSubmit prop to MarkdownEditor for Cmd/Ctrl+Enter support - Agents from the company are passed as mentionable options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import type { IssueComment, Agent } from "@paperclip/shared";
|
import type { IssueComment, Agent } from "@paperclip/shared";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { formatDate } from "../lib/utils";
|
import { formatDate } from "../lib/utils";
|
||||||
|
|
||||||
interface CommentWithRunMeta extends IssueComment {
|
interface CommentWithRunMeta extends IssueComment {
|
||||||
@@ -25,6 +25,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
|||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(true);
|
const [reopen, setReopen] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||||
|
|
||||||
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
|
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
|
||||||
|
|
||||||
@@ -34,8 +35,16 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
|||||||
[comments],
|
[comments],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleSubmit(e?: React.FormEvent) {
|
// Build mention options from agent map
|
||||||
e?.preventDefault();
|
const mentions = useMemo<MentionOption[]>(() => {
|
||||||
|
if (!agentMap) return [];
|
||||||
|
return Array.from(agentMap.values()).map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
}));
|
||||||
|
}, [agentMap]);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
@@ -90,18 +99,15 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Textarea
|
<MarkdownEditor
|
||||||
placeholder="Leave a comment..."
|
ref={editorRef}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={setBody}
|
||||||
onKeyDown={(e) => {
|
placeholder="Leave a comment..."
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
mentions={mentions}
|
||||||
e.preventDefault();
|
onSubmit={handleSubmit}
|
||||||
handleSubmit();
|
contentClassName="min-h-[60px] text-sm"
|
||||||
}
|
|
||||||
}}
|
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
{isClosed && (
|
{isClosed && (
|
||||||
@@ -115,11 +121,11 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
|||||||
Re-open
|
Re-open
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" size="sm" disabled={!body.trim() || submitting}>
|
<Button size="sm" disabled={!body.trim() || submitting} onClick={handleSubmit}>
|
||||||
{submitting ? "Posting..." : "Comment"}
|
{submitting ? "Posting..." : "Comment"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type DragEvent,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
MDXEditor,
|
MDXEditor,
|
||||||
type MDXEditorMethods,
|
type MDXEditorMethods,
|
||||||
@@ -14,6 +23,15 @@ import {
|
|||||||
} from "@mdxeditor/editor";
|
} from "@mdxeditor/editor";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
/* ---- Mention types ---- */
|
||||||
|
|
||||||
|
export interface MentionOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Editor props ---- */
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -23,12 +41,88 @@ interface MarkdownEditorProps {
|
|||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
bordered?: boolean;
|
bordered?: boolean;
|
||||||
|
/** List of mentionable users/agents. Enables @-mention autocomplete. */
|
||||||
|
mentions?: MentionOption[];
|
||||||
|
/** Called on Cmd/Ctrl+Enter */
|
||||||
|
onSubmit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarkdownEditorRef {
|
export interface MarkdownEditorRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Mention detection helpers ---- */
|
||||||
|
|
||||||
|
interface MentionState {
|
||||||
|
query: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
textNode: Text;
|
||||||
|
atPos: number;
|
||||||
|
endPos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Walk backwards from cursor to find @
|
||||||
|
let atPos = -1;
|
||||||
|
for (let i = offset - 1; i >= 0; i--) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (ch === "@") {
|
||||||
|
if (i === 0 || /\s/.test(text[i - 1])) {
|
||||||
|
atPos = i;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (/\s/.test(ch)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atPos === -1) return null;
|
||||||
|
|
||||||
|
const query = text.slice(atPos + 1, offset);
|
||||||
|
|
||||||
|
// Get position relative to container
|
||||||
|
const tempRange = document.createRange();
|
||||||
|
tempRange.setStart(textNode, atPos);
|
||||||
|
tempRange.setEnd(textNode, atPos + 1);
|
||||||
|
const rect = tempRange.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
top: rect.bottom - containerRect.top,
|
||||||
|
left: rect.left - containerRect.left,
|
||||||
|
textNode: textNode as Text,
|
||||||
|
atPos,
|
||||||
|
endPos: offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMention(state: MentionState, option: MentionOption) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(state.textNode, state.atPos);
|
||||||
|
range.setEnd(state.textNode, state.endPos);
|
||||||
|
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
|
||||||
|
// insertText preserves undo stack and triggers editor onChange
|
||||||
|
document.execCommand("insertText", false, `@${option.name} `);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Component ---- */
|
||||||
|
|
||||||
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -38,6 +132,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
onBlur,
|
onBlur,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
bordered = true,
|
bordered = true,
|
||||||
|
mentions,
|
||||||
|
onSubmit,
|
||||||
}: MarkdownEditorProps, forwardedRef) {
|
}: MarkdownEditorProps, forwardedRef) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const ref = useRef<MDXEditorMethods>(null);
|
const ref = useRef<MDXEditorMethods>(null);
|
||||||
@@ -46,6 +142,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
|
|
||||||
|
// Mention state
|
||||||
|
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||||
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
|
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
|
||||||
|
|
||||||
|
const filteredMentions = useMemo(() => {
|
||||||
|
if (!mentionState || !mentions) return [];
|
||||||
|
const q = mentionState.query.toLowerCase();
|
||||||
|
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||||
|
}, [mentionState?.query, mentions]);
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||||
@@ -66,7 +173,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const withImage = Boolean(imageHandler);
|
|
||||||
const all: RealmPlugin[] = [
|
const all: RealmPlugin[] = [
|
||||||
headingsPlugin(),
|
headingsPlugin(),
|
||||||
listsPlugin(),
|
listsPlugin(),
|
||||||
@@ -89,6 +195,37 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// Mention detection: listen for selection changes and input events
|
||||||
|
const checkMention = useCallback(() => {
|
||||||
|
if (!mentions || mentions.length === 0 || !containerRef.current) {
|
||||||
|
setMentionState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = detectMention(containerRef.current);
|
||||||
|
if (result) {
|
||||||
|
setMentionState(result);
|
||||||
|
setMentionIndex(0);
|
||||||
|
} else {
|
||||||
|
setMentionState(null);
|
||||||
|
}
|
||||||
|
}, [mentions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mentions || mentions.length === 0) return;
|
||||||
|
|
||||||
|
document.addEventListener("selectionchange", checkMention);
|
||||||
|
return () => document.removeEventListener("selectionchange", checkMention);
|
||||||
|
}, [checkMention, mentions]);
|
||||||
|
|
||||||
|
const selectMention = useCallback(
|
||||||
|
(option: MentionOption) => {
|
||||||
|
if (!mentionState) return;
|
||||||
|
insertMention(mentionState, option);
|
||||||
|
setMentionState(null);
|
||||||
|
},
|
||||||
|
[mentionState],
|
||||||
|
);
|
||||||
|
|
||||||
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
||||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||||
}
|
}
|
||||||
@@ -104,6 +241,43 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
isDragOver && "ring-1 ring-primary/60 bg-accent/20",
|
isDragOver && "ring-1 ring-primary/60 bg-accent/20",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onKeyDownCapture={(e) => {
|
||||||
|
// Cmd/Ctrl+Enter to submit
|
||||||
|
if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mention keyboard navigation
|
||||||
|
if (mentionActive && filteredMentions.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setMentionIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectMention(filteredMentions[mentionIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setMentionState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragEnter={(evt) => {
|
onDragEnter={(evt) => {
|
||||||
if (!canDropImage || !hasFilePayload(evt)) return;
|
if (!canDropImage || !hasFilePayload(evt)) return;
|
||||||
dragDepthRef.current += 1;
|
dragDepthRef.current += 1;
|
||||||
@@ -114,7 +288,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
evt.dataTransfer.dropEffect = "copy";
|
evt.dataTransfer.dropEffect = "copy";
|
||||||
}}
|
}}
|
||||||
onDragLeave={(evt) => {
|
onDragLeave={() => {
|
||||||
if (!canDropImage) return;
|
if (!canDropImage) return;
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||||
@@ -141,6 +315,33 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
overlayContainer={containerRef.current}
|
overlayContainer={containerRef.current}
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Mention dropdown */}
|
||||||
|
{mentionActive && filteredMentions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute z-50 min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||||
|
style={{ top: mentionState.top + 4, left: mentionState.left }}
|
||||||
|
>
|
||||||
|
{filteredMentions.map((option, i) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // prevent blur
|
||||||
|
selectMention(option);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setMentionIndex(i)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">@</span>
|
||||||
|
<span>{option.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isDragOver && canDropImage && (
|
{isDragOver && canDropImage && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
Reference in New Issue
Block a user