From 8f2ea97480de7c398dc79f2f7dc7c875bdf3b095 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 23 Apr 2026 09:14:42 -0500 Subject: [PATCH] PAP-2086 route issue description pills through MarkdownBody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description bodies rendered via InlineEditor previously stayed in the MDX editor even at rest, which produced plain outlined PAP-XXXX chips with no status dot and no quicklook — the QA gap flagged on the ticket. Idle multiline now renders MarkdownBody so issue refs pick up IssueReferencePill + Link (status circle + hover preview card); click or drag-enter swaps the MDX editor back in, and we only return to the preview once the editor has been focused at least once, blurred, and autosave has settled. Also fix the chip typography user feedback: switch vertical-align from middle to baseline so pills sit on the text line, and neutralize the inline-code monospace/gray styling that leaked into backtick-wrapped chip labels in both the MDX editor and the MarkdownBody preview. Co-Authored-By: Paperclip --- ui/src/components/InlineEditor.test.tsx | 49 +++++++++++++++++- ui/src/components/InlineEditor.tsx | 69 ++++++++++++++++++++++--- ui/src/index.css | 37 +++++++++++-- 3 files changed, 143 insertions(+), 12 deletions(-) diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx index 830d2cedc6d..f41a31cc690 100644 --- a/ui/src/components/InlineEditor.test.tsx +++ b/ui/src/components/InlineEditor.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, forwardRef, useImperativeHandle, useRef } from "react"; +import { act, forwardRef, useImperativeHandle, useRef, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -24,8 +24,22 @@ vi.mock("./MarkdownEditor", () => ({ }), })); +vi.mock("./MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + import { InlineEditor, queueContainedBlurCommit } from "./InlineEditor"; +/** Enter multiline edit mode by clicking the preview surface. */ +function enterMultilineEdit(container: HTMLDivElement) { + const preview = container.querySelector('[data-testid="multiline-md-preview"]'); + if (preview) { + preview.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -139,6 +153,11 @@ describe("InlineEditor", () => { root.render(); }); + // Non-empty value renders MarkdownBody preview; click to enter edit mode. + act(() => { + enterMultilineEdit(container); + }); + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); expect(textarea).not.toBeNull(); @@ -165,6 +184,29 @@ describe("InlineEditor", () => { outside.remove(); }); + it("multiline defaults to MarkdownBody preview when value is non-empty, swaps to editor on click", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-testid="multiline-md-preview"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="multiline-md-mock"]')).toBeNull(); + + act(() => { + enterMultilineEdit(container); + }); + + expect(container.querySelector('[data-testid="multiline-md-mock"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="multiline-md-preview"]')).toBeNull(); + + act(() => { + root.unmount(); + }); + }); + it("syncs a new multiline value while focused when the user has not edited locally", () => { const onSave = vi.fn().mockResolvedValue(undefined); const root = createRoot(container); @@ -200,6 +242,11 @@ describe("InlineEditor", () => { root.render(); }); + // Non-empty value renders MarkdownBody preview; click to enter edit mode. + act(() => { + enterMultilineEdit(container); + }); + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); expect(textarea).not.toBeNull(); diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 2c878b3fa22..de45ee2a826 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "../lib/utils"; +import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; @@ -52,6 +53,7 @@ export function InlineEditor({ mentions, }: InlineEditorProps) { const [editing, setEditing] = useState(false); + const [multilineEditing, setMultilineEditing] = useState(false); const [multilineFocused, setMultilineFocused] = useState(false); const [draft, setDraft] = useState(value); const lastPropValueRef = useRef(value); @@ -59,6 +61,8 @@ export function InlineEditor({ const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); const blurCommitFrameRef = useRef<(() => void) | null>(null); + const justEnteredEditRef = useRef(false); + const hasBeenFocusedRef = useRef(false); const { state: autosaveState, markDirty, @@ -106,12 +110,27 @@ export function InlineEditor({ }, [editing, autoSize]); useEffect(() => { - if (!editing || !multiline) return; - const frame = requestAnimationFrame(() => { - markdownRef.current?.focus(); - }); - return () => cancelAnimationFrame(frame); - }, [editing, multiline]); + if (!multilineEditing || !multiline) return; + if (!justEnteredEditRef.current) return; + justEnteredEditRef.current = false; + markdownRef.current?.focus(); + }, [multilineEditing, multiline]); + + // Once the editor has been focused at least once, it's blurred, and any + // autosave has settled, swap back to the MarkdownBody preview so inline + // issue refs render with status + quicklook. + useEffect(() => { + if (multilineFocused) { + hasBeenFocusedRef.current = true; + return; + } + if (!multiline || !multilineEditing) return; + if (!hasBeenFocusedRef.current) return; + if (autosaveState !== "idle") return; + hasBeenFocusedRef.current = false; + setMultilineEditing(false); + }, [multiline, multilineEditing, multilineFocused, autosaveState]); + const commit = useCallback(async (nextValue = draft) => { const valueToSave = nextValue.trim(); @@ -176,6 +195,8 @@ export function InlineEditor({ setDraft(value); if (multiline) { setMultilineFocused(false); + setMultilineEditing(false); + hasBeenFocusedRef.current = false; if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } @@ -212,6 +233,36 @@ export function InlineEditor({ }, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]); if (multiline) { + const hasValue = Boolean(value.trim()); + const showEditor = multilineEditing || multilineFocused || !hasValue; + + if (!showEditor) { + const enterEditMode = () => { + justEnteredEditRef.current = true; + setMultilineEditing(true); + }; + return ( +
{ + if (event.defaultPrevented) return; + const target = event.target as HTMLElement | null; + if (target && target.closest("a,button,[data-mention-kind],[data-radix-popper-content-wrapper]")) { + return; + } + enterEditMode(); + }} + onDragEnter={() => enterEditMode()} + role="textbox" + aria-label={placeholder} + > + + {value} + +
+ ); + } + return (
{ + onFocusCapture={(event) => { + // Ignore focus events where the active element isn't actually inside + // the wrapper (React 19 can emit a synthetic focus after a blur). + const active = document.activeElement; + if (!(active instanceof Node) || !event.currentTarget.contains(active)) return; cancelPendingBlurCommit(); setMultilineFocused(true); }} diff --git a/ui/src/index.css b/ui/src/index.css index 95643466a50..12077489d8b 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -448,11 +448,23 @@ font-size: 0.75rem; line-height: 1.3; text-decoration: none; - vertical-align: middle; + vertical-align: baseline; white-space: nowrap; user-select: none; } +/* Strip the MDXEditor's default inline-code styling from the text inside chips + (the link label otherwise picks up a monospace font + gray tint). */ +.paperclip-mdxeditor-content a.paperclip-mention-chip, +.paperclip-mdxeditor-content a.paperclip-mention-chip code, +.paperclip-mdxeditor-content a.paperclip-project-mention-chip, +.paperclip-mdxeditor-content a.paperclip-project-mention-chip code { + font-family: inherit; + background: none; + color: inherit; + padding: 0; +} + .paperclip-mdxeditor-content a.paperclip-mention-chip::before, a.paperclip-mention-chip::before { content: ""; @@ -832,9 +844,11 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { background: transparent; } -/* Project mention chips rendered inside MarkdownBody */ +/* Mention chips rendered inline in prose (MarkdownBody or inline anchors) */ a.paperclip-mention-chip, -a.paperclip-project-mention-chip { +a.paperclip-project-mention-chip, +span.paperclip-mention-chip, +span.paperclip-project-mention-chip { display: inline-flex; align-items: center; gap: 0.25rem; @@ -845,10 +859,25 @@ a.paperclip-project-mention-chip { font-size: 0.75rem; line-height: 1.3; text-decoration: none; - vertical-align: middle; + /* Align the pill relative to the surrounding text baseline instead of its + x-height midpoint so it sits on the text line rather than floating above. */ + vertical-align: baseline; white-space: nowrap; } +/* When the identifier inside a chip is backtick-wrapped in markdown, strip the + inline-code monospace/gray styling so the pill label reads cleanly. */ +.paperclip-markdown a.paperclip-mention-chip code, +.paperclip-markdown a.paperclip-project-mention-chip code, +.paperclip-markdown span.paperclip-mention-chip code, +.paperclip-markdown span.paperclip-project-mention-chip code { + font-family: inherit; + background: none; + color: inherit; + padding: 0; + font-size: inherit; +} + /* Keep MDXEditor popups above app dialogs, even when they portal to . */ [class*="_popupContainer_"] { z-index: 81 !important;