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;