PAP-2086 route issue description pills through MarkdownBody

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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-24 00:24:10 -05:00
1 parent 1238130944
commit 8f2ea97480
3 files changed
+143 -12

No files matched your search

+48 -1
View File
@@ -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 }) => (
<div data-testid="multiline-md-preview">{children}</div>
),
}));
import { InlineEditor, queueContainedBlurCommit } from "./InlineEditor";
/** Enter multiline edit mode by clicking the preview surface. */
function enterMultilineEdit(container: HTMLDivElement) {
const preview = container.querySelector<HTMLDivElement>('[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(<InlineEditor value="hello" multiline nullable onSave={onSave} />);
});
// Non-empty value renders MarkdownBody preview; click to enter edit mode.
act(() => {
enterMultilineEdit(container);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[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(<InlineEditor value="Hello world" multiline onSave={onSave} />);
});
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(<InlineEditor value="Original" multiline onSave={onSave} />);
});
// Non-empty value renders MarkdownBody preview; click to enter edit mode.
act(() => {
enterMultilineEdit(container);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
+62 -7
View File
@@ -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<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div
className={cn(markdownPad, "rounded transition-colors hover:bg-accent/20")}
onClick={(event) => {
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}
>
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{value}
</MarkdownBody>
</div>
);
}
return (
<div
className={cn(
@@ -219,7 +270,11 @@ export function InlineEditor({
"rounded transition-colors",
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
)}
onFocusCapture={() => {
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);
}}
+33 -4
View File
@@ -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 <body>. */
[class*="_popupContainer_"] {
z-index: 81 !important;