mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
[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:
parent
5d1ed71779
commit
6e6f538630
41 changed files with 4141 additions and 590 deletions
|
|
@ -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("");
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue