[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:
Dotta
2026-04-24 09:39:21 -05:00
committed by GitHub
parent 7ad225a198
commit 4fdbbeced3
9 changed files with 314 additions and 44 deletions
+90 -5
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,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}