diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 8bf008bd..f158a5e9 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { parse as parseEnvContents } from "dotenv"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, @@ -540,13 +541,12 @@ describe("realizeExecutionWorkspace", () => { path.join(expectedInstanceRoot, "secrets", "master.key"), ); expect(envContents).not.toContain("DATABASE_URL="); - expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`); - expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`); - expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); - expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true"); - expect(envContents).toContain( - `PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`, - ); + const envVars = parseEnvContents(envContents); + expect(envVars.PAPERCLIP_HOME).toBe(isolatedWorktreeHome); + expect(envVars.PAPERCLIP_INSTANCE_ID).toBe(expectedInstanceId); + expect(await fs.realpath(envVars.PAPERCLIP_CONFIG!)).toBe(await fs.realpath(configPath)); + expect(envVars.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(envVars.PAPERCLIP_WORKTREE_NAME).toBe("PAP-885-show-worktree-banner"); process.chdir(workspace.cwd); expect(resolvePaperclipConfigPath()).toBe(configPath); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index fb9b0fff..b77f926c 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1,4 +1,5 @@ import { + type ClipboardEvent, forwardRef, useCallback, useEffect, @@ -32,6 +33,7 @@ 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, normalizePastedMarkdown } from "../lib/markdownPaste"; import { cn } from "../lib/utils"; /* ---- Mention types ---- */ @@ -167,6 +169,24 @@ function detectMention(container: HTMLElement): MentionState | null { }; } +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; +} + function mentionMarkdown(option: MentionOption): string { if (option.kind === "project" && option.projectId) { return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `; @@ -199,11 +219,17 @@ export const MarkdownEditor = forwardRef onSubmit, }: MarkdownEditorProps, forwardedRef) { const containerRef = useRef(null); - const editorRef = useRef(null); + const ref = useRef(null); + const valueRef = useRef(value); + valueRef.current = value; const latestValueRef = useRef(value); - const latestPropValueRef = useRef(value); - const pendingExternalValueRef = useRef(null); - const isFocusedRef = useRef(false); + 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(null); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); @@ -237,9 +263,19 @@ export const MarkdownEditor = forwardRef return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8); }, [mentionState?.query, mentions]); + const setEditorRef = useCallback((instance: MDXEditorMethods | null) => { + ref.current = instance; + if (instance) { + const v = valueRef.current; + echoIgnoreMarkdownRef.current = v; + instance.setMarkdown(v); + latestValueRef.current = v; + } + }, []); + useImperativeHandle(forwardedRef, () => ({ focus: () => { - editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" }); + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }, }), []); @@ -266,10 +302,11 @@ export const MarkdownEditor = forwardRef ); if (updated !== current) { latestValueRef.current = updated; - editorRef.current?.setMarkdown(updated); + echoIgnoreMarkdownRef.current = updated; + ref.current?.setMarkdown(updated); onChange(updated); requestAnimationFrame(() => { - editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" }); + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }); } }, 100); @@ -303,29 +340,14 @@ export const MarkdownEditor = forwardRef return all; }, [hasImageUpload]); - const handleEditorRef = useCallback((instance: MDXEditorMethods | null) => { - editorRef.current = instance; - if (!instance) return; - - const pendingValue = pendingExternalValueRef.current; - if (pendingValue !== null && pendingValue !== latestValueRef.current) { - instance.setMarkdown(pendingValue); - latestValueRef.current = pendingValue; - } - pendingExternalValueRef.current = null; - }, []); - - latestPropValueRef.current = value; - useEffect(() => { if (value !== latestValueRef.current) { - if (!editorRef.current) { - pendingExternalValueRef.current = value; - return; + if (ref.current) { + // Pair with onChange echo suppression (echoIgnoreMarkdownRef). + echoIgnoreMarkdownRef.current = value; + ref.current.setMarkdown(value); + latestValueRef.current = value; } - editorRef.current.setMarkdown(value); - latestValueRef.current = value; - pendingExternalValueRef.current = null; } }, [value]); @@ -416,7 +438,8 @@ export const MarkdownEditor = forwardRef const next = applyMention(current, state.query, option); if (next !== current) { latestValueRef.current = next; - editorRef.current?.setMarkdown(next); + echoIgnoreMarkdownRef.current = next; + ref.current?.setMarkdown(next); onChange(next); } @@ -486,6 +509,19 @@ export const MarkdownEditor = forwardRef } const canDropImage = Boolean(imageUploadHandler); + const handlePasteCapture = useCallback((event: ClipboardEvent) => { + 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(normalizePastedMarkdown(rawText)); + }, []); return (
dragDepthRef.current = 0; setIsDragOver(false); }} - onFocusCapture={() => { - isFocusedRef.current = true; - }} - onBlurCapture={() => { - isFocusedRef.current = false; - }} + onPasteCapture={handlePasteCapture} > { - const externalValue = latestPropValueRef.current; - if (!isFocusedRef.current) { - if (next === externalValue) { - latestValueRef.current = externalValue; - return; - } - - latestValueRef.current = externalValue; - if (editorRef.current) { - editorRef.current.setMarkdown(externalValue); - pendingExternalValueRef.current = null; - } else { - pendingExternalValueRef.current = externalValue; - } + 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 === "" && value !== "") { + echoIgnoreMarkdownRef.current = value; + ref.current?.setMarkdown(value); + return; + } + } latestValueRef.current = next; onChange(next); }} diff --git a/ui/src/lib/markdownPaste.test.ts b/ui/src/lib/markdownPaste.test.ts new file mode 100644 index 00000000..9a84f903 --- /dev/null +++ b/ui/src/lib/markdownPaste.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "./markdownPaste"; + +describe("markdownPaste", () => { + it("normalizes windows line endings", () => { + expect(normalizePastedMarkdown("a\r\nb\r\n")).toBe("a\nb\n"); + }); + + it("normalizes old mac line endings", () => { + expect(normalizePastedMarkdown("a\rb\r")).toBe("a\nb\n"); + }); + + it("treats markdown blocks as markdown paste", () => { + expect(looksLikeMarkdownPaste("# Title\n\n- item 1\n- item 2")).toBe(true); + }); + + it("treats a fenced code block as markdown paste", () => { + expect(looksLikeMarkdownPaste("```\nconst x = 1;\n```")).toBe(true); + }); + + it("treats a tilde fence as markdown paste", () => { + expect(looksLikeMarkdownPaste("~~~\nraw\n~~~")).toBe(true); + }); + + it("treats a blockquote as markdown paste", () => { + expect(looksLikeMarkdownPaste("> some quoted text")).toBe(true); + }); + + it("treats an ordered list as markdown paste", () => { + expect(looksLikeMarkdownPaste("1. first\n2. second")).toBe(true); + }); + + it("treats a table row as markdown paste", () => { + expect(looksLikeMarkdownPaste("| col1 | col2 |")).toBe(true); + }); + + it("treats horizontal rules as markdown paste", () => { + expect(looksLikeMarkdownPaste("---")).toBe(true); + expect(looksLikeMarkdownPaste("***")).toBe(true); + expect(looksLikeMarkdownPaste("___")).toBe(true); + }); + + it("leaves plain multi-line text on the native paste path", () => { + expect(looksLikeMarkdownPaste("first paragraph\nsecond paragraph")).toBe(false); + }); + + it("leaves single-line plain text on the native paste path", () => { + expect(looksLikeMarkdownPaste("just a sentence")).toBe(false); + }); +}); diff --git a/ui/src/lib/markdownPaste.ts b/ui/src/lib/markdownPaste.ts new file mode 100644 index 00000000..80a8886d --- /dev/null +++ b/ui/src/lib/markdownPaste.ts @@ -0,0 +1,23 @@ +const BLOCK_MARKER_PATTERNS = [ + /^#{1,6}\s+/m, + /^>\s+/m, + /^[-*+]\s+/m, + /^\d+\.\s+/m, + /^```/m, + /^~~~/m, + /^\|.+\|$/m, + /^---$/m, + /^\*\*\*$/m, + /^___$/m, +]; + +export function normalizePastedMarkdown(text: string): string { + return text.replace(/\r\n?/g, "\n"); +} + +export function looksLikeMarkdownPaste(text: string): boolean { + const normalized = normalizePastedMarkdown(text).trim(); + if (!normalized) return false; + + return BLOCK_MARKER_PATTERNS.some((pattern) => pattern.test(normalized)); +}