[codex] Improve issue detail and issue-list UX (#3678)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - A core part of that is the operator experience around reading issue
state, agent chat, and sub-task structure
> - The current branch had a long run of issue-detail and issue-list UX
fixes that all improve how humans follow and steer active work
> - Those changes mostly live in the UI/chat surface and should be
reviewed together instead of mixed with workspace/runtime work
> - This pull request packages the issue-detail, chat, markdown, and
sub-issue list improvements into one standalone change
> - The benefit is a cleaner, less jumpy, more reliable issue workflow
on desktop and mobile without coupling it to unrelated server/runtime
refactors

## What Changed

- Stabilized issue chat runtime wiring, optimistic comment handling,
queued-comment cancellation, and composer anchoring during live updates
- Fixed several issue-detail rendering and navigation regressions
including placeholder bleed, local polling scope, mobile inbox-to-issue
transitions, and visible refresh resets
- Improved markdown and rich-content handling with advisory image
normalization, editor fallback behavior, touch mention recovery, and
`issue:` quicklook links
- Refined sub-issue behavior with parent-derived defaults, current-user
inheritance fixes, empty-state cleanup, and a reusable issue-list
presentation for sub-issues
- Added targeted UI tests for the new issue-detail, chat scroll/message,
placeholder-data, markdown, and issue-list behaviors

## Verification

- `pnpm vitest run ui/src/components/IssueChatThread.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/IssuesList.test.tsx
ui/src/context/LiveUpdatesProvider.test.tsx
ui/src/lib/issue-chat-messages.test.ts
ui/src/lib/issue-chat-scroll.test.ts
ui/src/lib/issue-detail-subissues.test.ts
ui/src/lib/query-placeholder-data.test.tsx
ui/src/hooks/usePaperclipIssueRuntime.test.tsx`

## Risks

- Medium: this branch touches the highest-traffic issue-detail UI paths,
so regressions would show up as chat/thread or sub-issue UX glitches
- The changes are UI-heavy and would benefit from reviewer screenshots
or a quick manual browser pass before merge

## Model Used

- OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact
deployed model ID is not exposed in this environment), reasoning
enabled, tool use and local code execution enabled

## 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 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-14 12:50:48 -05:00 committed by GitHub
parent 5d1ed71779
commit 6e6f538630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4141 additions and 590 deletions

View file

@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildSkillMentionHref } from "@paperclipai/shared";
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import {
computeMentionMenuPosition,
findClosestAutocompleteAnchor,
@ -16,6 +16,9 @@ import {
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
emitMountParseError: false,
emitMountSilentEmptyState: false,
markdownValues: [] as string[],
}));
vi.mock("@mdxeditor/editor", async () => {
@ -36,19 +39,29 @@ vi.mock("@mdxeditor/editor", async () => {
markdown,
placeholder,
onChange,
onError,
className,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
onError?: (error: unknown) => void;
className?: string;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
mdxEditorMockState.markdownValues.push(markdown);
const [content, setContent] = React.useState(markdown);
const editableRef = React.useRef<HTMLDivElement>(null);
const handle = React.useMemo(() => ({
setMarkdown: (value: string) => setContent(value),
focus: () => {},
focus: () => editableRef.current?.focus(),
}), []);
React.useEffect(() => {
setContent(markdown);
}, [markdown]);
React.useEffect(() => {
setForwardedRef(forwardedRef, null);
const timer = window.setTimeout(() => {
@ -57,6 +70,16 @@ vi.mock("@mdxeditor/editor", async () => {
setContent("");
onChange?.("");
}
if (mdxEditorMockState.emitMountSilentEmptyState) {
setContent("");
}
if (mdxEditorMockState.emitMountParseError) {
setContent("");
onError?.({
error: "Unsupported markdown syntax",
source: markdown,
});
}
}, 0);
return () => {
window.clearTimeout(timer);
@ -64,7 +87,17 @@ vi.mock("@mdxeditor/editor", async () => {
};
}, []);
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
return (
<div
ref={editableRef}
data-testid="mdx-editor"
className={className}
contentEditable
suppressContentEditableWarning
>
{content || placeholder || ""}
</div>
);
});
return {
@ -105,16 +138,33 @@ async function flush() {
describe("MarkdownEditor", () => {
let container: HTMLDivElement;
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
originalRangeRect = Range.prototype.getBoundingClientRect;
Range.prototype.getBoundingClientRect = () => ({
x: 32,
y: 24,
width: 12,
height: 18,
top: 24,
right: 44,
bottom: 42,
left: 32,
toJSON: () => ({}),
});
});
afterEach(() => {
container.remove();
Range.prototype.getBoundingClientRect = originalRangeRect;
vi.clearAllMocks();
mdxEditorMockState.emitMountEmptyReset = false;
mdxEditorMockState.emitMountParseError = false;
mdxEditorMockState.emitMountSilentEmptyState = false;
mdxEditorMockState.markdownValues = [];
});
it("applies async external value updates once the editor ref becomes ready", async () => {
@ -172,6 +222,94 @@ describe("MarkdownEditor", () => {
});
});
it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value={`Before\n\n<img width="10" height="10" alt="image" src="https://example.com/test.png" />\n\nAfter`}
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)");
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
expect(container.textContent).toContain("Before");
expect(container.textContent).toContain("After");
await act(async () => {
root.unmount();
});
});
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
mdxEditorMockState.emitMountParseError = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Affected versions: <= v0.3.1"
onChange={handleChange}
placeholder="Markdown body"
/>,
);
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => {
mdxEditorMockState.emitMountSilentEmptyState = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Affected versions: <= v0.3.1"
onChange={handleChange}
placeholder="Add a description..."
/>,
);
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
expect(container.textContent).toContain("Rich editor unavailable for this markdown");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
expect(
computeMentionMenuPosition(
@ -312,4 +450,64 @@ describe("MarkdownEditor", () => {
editable.remove();
});
it("accepts mention selection from touchstart taps", async () => {
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="@Pap"
onChange={handleChange}
mentions={[
{
id: "project:project-123",
kind: "project",
name: "Paperclip App",
projectId: "project-123",
projectColor: "#336699",
},
]}
/>,
);
});
await flush();
const editable = container.querySelector('[contenteditable="true"]');
expect(editable).not.toBeNull();
const textNode = editable?.firstChild;
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textNode!, "@Pap".length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
act(() => {
document.dispatchEvent(new Event("selectionchange"));
});
await flush();
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
.find((node) => node.textContent?.includes("Paperclip App"));
expect(option).toBeTruthy();
act(() => {
option?.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true }));
});
expect(handleChange).toHaveBeenCalledWith(
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
);
await act(async () => {
root.unmount();
});
});
});