[codex] Refine markdown issue reference rendering (#4382)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Task references are a core part of how operators understand issue relationships across the UI > - Those references appear both in markdown bodies and in sidebar relationship panels > - The rendering had drifted between surfaces, and inline markdown pills were reading awkwardly inside prose and lists > - This pull request unifies the underlying issue-reference treatment, routes issue descriptions through `MarkdownBody`, and switches inline markdown references to a cleaner text-link presentation > - The benefit is more consistent issue-reference UX with better readability in markdown-heavy views ## What Changed - unified sidebar and markdown issue-reference rendering around the shared issue-reference components - routed resting issue descriptions through `MarkdownBody` so description previews inherit the richer issue-reference treatment - replaced inline markdown pill chrome with a cleaner inline reference presentation for prose contexts - added and updated UI tests for `MarkdownBody` and `InlineEditor` ## Verification - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/MarkdownBody.test.tsx ui/src/components/InlineEditor.test.tsx` ## Risks - Moderate UI risk: issue-reference rendering now differs intentionally between inline markdown and relationship sidebars, so regressions would show up as styling or hover-preview mismatches > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex GPT-5-based coding agent with tool use and code execution in the Codex CLI environment ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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,9 @@ export function InlineEditor({
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const blurCommitFrameRef = useRef<(() => void) | null>(null);
|
||||
const pendingFocusFrameRef = useRef<number | null>(null);
|
||||
const justEnteredEditRef = useRef(false);
|
||||
const hasBeenFocusedRef = useRef(false);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
@@ -86,6 +91,10 @@ export function InlineEditor({
|
||||
blurCommitFrameRef.current();
|
||||
blurCommitFrameRef.current = null;
|
||||
}
|
||||
if (pendingFocusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(pendingFocusFrameRef.current);
|
||||
pendingFocusFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -106,12 +115,39 @@ export function InlineEditor({
|
||||
}, [editing, autoSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing || !multiline) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!multilineEditing || !multiline) return;
|
||||
if (!justEnteredEditRef.current) return;
|
||||
justEnteredEditRef.current = false;
|
||||
if (pendingFocusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(pendingFocusFrameRef.current);
|
||||
}
|
||||
pendingFocusFrameRef.current = requestAnimationFrame(() => {
|
||||
pendingFocusFrameRef.current = null;
|
||||
markdownRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [editing, multiline]);
|
||||
return () => {
|
||||
if (pendingFocusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(pendingFocusFrameRef.current);
|
||||
pendingFocusFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [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 +212,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 +250,45 @@ export function InlineEditor({
|
||||
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]);
|
||||
|
||||
if (multiline) {
|
||||
const previewValue = autosaveState === "saved" || autosaveState === "idle" ? draft : value;
|
||||
const hasValue = Boolean(previewValue.trim());
|
||||
const showEditor = multilineEditing || multilineFocused || !hasValue;
|
||||
|
||||
if (!showEditor) {
|
||||
const enterEditMode = () => {
|
||||
if (multilineEditing) return;
|
||||
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()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
enterEditMode();
|
||||
}}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder}
|
||||
tabIndex={0}
|
||||
>
|
||||
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
||||
{previewValue}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -219,12 +296,20 @@ 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);
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (pendingFocusFrameRef.current !== null) {
|
||||
cancelAnimationFrame(pendingFocusFrameRef.current);
|
||||
pendingFocusFrameRef.current = null;
|
||||
}
|
||||
scheduleBlurCommit(event.currentTarget);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
Reference in New Issue
Block a user