fix(ui): polish issue detail timelines and attachments

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 11:51:40 -05:00
parent 36376968af
commit bd6d07d0b4
25 changed files with 2020 additions and 82 deletions

View file

@ -0,0 +1,123 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommentThread } from "./CommentThread";
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: ({ value, onChange, placeholder }: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) => (
<textarea
aria-label="Comment editor"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock("./InlineEntitySelector", () => ({
InlineEntitySelector: () => null,
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("CommentThread", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
container.remove();
});
it("renders historical runs as timeline rows using the finished time", () => {
const root = createRoot(container);
const agent: Agent = {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
urlKey: "codexcoder",
role: "engineer",
title: null,
icon: "code",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[]}
linkedRuns={[{
runId: "run-12345678abcd",
status: "succeeded",
agentId: "agent-1",
createdAt: "2026-03-11T07:00:00.000Z",
startedAt: "2026-03-11T08:00:00.000Z",
finishedAt: "2026-03-11T10:00:00.000Z",
}]}
agentMap={new Map([["agent-1", agent]])}
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
const runRow = container.querySelector("#run-run-12345678abcd") as HTMLDivElement | null;
expect(runRow).not.toBeNull();
expect(runRow?.className).toContain("py-1.5");
expect(runRow?.className).toContain("items-center");
expect(runRow?.className).not.toContain("border");
expect(container.textContent).toContain("CodexCoder");
expect(container.textContent).toContain("succeeded");
expect(container.textContent).toContain("2h ago");
expect(container.textContent).not.toContain("4h ago");
const runLink = container.querySelector('a[href="/agents/agent-1/runs/run-12345678abcd"]') as HTMLAnchorElement | null;
expect(runLink?.textContent).toContain("run-1234");
expect(runLink?.className).toContain("rounded-md");
expect(runLink?.className).toContain("px-2");
act(() => {
root.unmount();
});
});
});

View file

@ -8,7 +8,8 @@ import type {
IssueComment,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Check, Copy, Paperclip } from "lucide-react";
import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { MarkdownBody } from "./MarkdownBody";
@ -16,7 +17,10 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { PluginSlotOutlet } from "@/plugins/slots";
@ -35,6 +39,7 @@ interface LinkedRunItem {
agentId: string;
createdAt: Date | string;
startedAt: Date | string | null;
finishedAt?: Date | string | null;
}
interface CommentReassignment {
@ -49,6 +54,7 @@ interface CommentThreadProps {
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: LinkedRunItem[];
timelineEvents?: IssueTimelineEvent[];
companyId?: string | null;
projectId?: string | null;
onVote?: (
@ -59,6 +65,7 @@ interface CommentThreadProps {
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
imageUploadHandler?: (file: File) => Promise<string>;
/** Callback to attach an image file to the parent issue (not inline in a comment). */
onAttachImage?: (file: File) => Promise<void>;
@ -118,6 +125,82 @@ function parseReassignment(target: string): CommentReassignment | null {
return null;
}
function humanizeValue(value: string | null): string {
if (!value) return "None";
return value.replace(/_/g, " ");
}
function formatTimelineAssigneeLabel(
assignee: IssueTimelineAssignee,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
) {
if (assignee.agentId) {
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
}
if (assignee.userId) {
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
}
return "Unassigned";
}
function formatTimelineActorName(
actorType: IssueTimelineEvent["actorType"],
actorId: string,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
) {
if (actorType === "agent") {
return agentMap?.get(actorId)?.name ?? actorId.slice(0, 8);
}
if (actorType === "system") {
return "System";
}
return formatAssigneeUserLabel(actorId, currentUserId) ?? "Board";
}
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
function formatRunStatusLabel(status: string) {
switch (status) {
case "timed_out":
return "timed out";
default:
return status.replace(/_/g, " ");
}
}
function runTimestamp(run: LinkedRunItem) {
return run.finishedAt ?? run.startedAt ?? run.createdAt;
}
function runStatusClass(status: string) {
switch (status) {
case "succeeded":
return "text-green-700 dark:text-green-300";
case "failed":
case "error":
return "text-red-700 dark:text-red-300";
case "timed_out":
return "text-orange-700 dark:text-orange-300";
case "running":
return "text-cyan-700 dark:text-cyan-300";
case "queued":
case "pending":
return "text-amber-700 dark:text-amber-300";
case "cancelled":
return "text-muted-foreground";
default:
return "text-foreground";
}
}
function CopyMarkdownButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
@ -277,11 +360,76 @@ function CommentCard({
type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
function TimelineEventCard({
event,
agentMap,
currentUserId,
}: {
event: IssueTimelineEvent;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
}) {
const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId);
return (
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
<Avatar size="sm" className="mt-0.5">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<a
href={`#activity-${event.id}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(event.createdAt)}
</a>
</div>
{event.statusChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Status
</span>
<span className="text-muted-foreground">
{humanizeValue(event.statusChange.from)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{humanizeValue(event.statusChange.to)}
</span>
</div>
) : null}
{event.assigneeChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Assignee
</span>
<span className="text-muted-foreground">
{formatTimelineAssigneeLabel(event.assigneeChange.from, agentMap, currentUserId)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineAssigneeLabel(event.assigneeChange.to, agentMap, currentUserId)}
</span>
</div>
) : null}
</div>
</div>
);
}
const TimelineList = memo(function TimelineList({
timeline,
agentMap,
currentUserId,
companyId,
projectId,
feedbackVoteByTargetId,
@ -293,6 +441,7 @@ const TimelineList = memo(function TimelineList({
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
companyId?: string | null;
projectId?: string | null;
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
@ -307,36 +456,54 @@ const TimelineList = memo(function TimelineList({
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
return <p className="text-sm text-muted-foreground">No comments or runs yet.</p>;
return <p className="text-sm text-muted-foreground">No timeline entries yet.</p>;
}
return (
<div className="space-y-3">
{timeline.map((item) => {
if (item.kind === "event") {
return (
<TimelineEventCard
key={`event:${item.event.id}`}
event={item.event}
agentMap={agentMap}
currentUserId={currentUserId}
/>
);
}
if (item.kind === "run") {
const run = item.run;
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
return (
<div key={`run:${run.runId}`} className="border border-border bg-accent/20 p-3 overflow-hidden min-w-0 rounded-sm">
<div className="flex items-center justify-between mb-2">
<Link to={`/agents/${run.agentId}`} className="hover:underline">
<Identity
name={agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8)}
size="sm"
/>
</Link>
<span className="text-xs text-muted-foreground">
{formatDateTime(run.startedAt ?? run.createdAt)}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Run</span>
<Link
to={`/agents/${run.agentId}/runs/${run.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
>
{run.runId.slice(0, 8)}
</Link>
<StatusBadge status={run.status} />
<div id={`run-${run.runId}`} key={`run:${run.runId}`} className="flex items-center gap-2.5 py-1.5">
<Avatar size="sm">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
<Link to={`/agents/${run.agentId}`} className="font-medium text-foreground transition-colors hover:underline">
{actorName}
</Link>
<span className="text-muted-foreground">run</span>
<Link
to={`/agents/${run.agentId}/runs/${run.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
{run.runId.slice(0, 8)}
</Link>
<span className={cn("font-medium", runStatusClass(run.status))}>
{formatRunStatusLabel(run.status)}
</span>
<a
href={`#run-${run.runId}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(runTimestamp(run))}
</a>
</div>
</div>
</div>
);
@ -370,11 +537,13 @@ export function CommentThread({
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
linkedRuns = [],
timelineEvents = [],
companyId,
projectId,
onVote,
onAdd,
agentMap,
currentUserId,
imageUploadHandler,
onAttachImage,
draftKey,
@ -408,18 +577,29 @@ export function CommentThread({
createdAtMs: new Date(comment.createdAt).getTime(),
comment,
}));
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
kind: "event",
id: event.id,
createdAtMs: new Date(event.createdAt).getTime(),
event,
}));
const runItems: TimelineItem[] = linkedRuns.map((run) => ({
kind: "run",
id: run.runId,
createdAtMs: new Date(run.startedAt ?? run.createdAt).getTime(),
createdAtMs: new Date(runTimestamp(run)).getTime(),
run,
}));
return [...commentItems, ...runItems].sort((a, b) => {
return [...commentItems, ...eventItems, ...runItems].sort((a, b) => {
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
if (a.kind === b.kind) return a.id.localeCompare(b.id);
return a.kind === "comment" ? -1 : 1;
const kindOrder = {
event: 0,
comment: 1,
run: 2,
} as const;
return kindOrder[a.kind] - kindOrder[b.kind];
});
}, [comments, linkedRuns]);
}, [comments, timelineEvents, linkedRuns]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
@ -496,7 +676,6 @@ export function CommentThread({
setSubmitting(true);
setBody("");
try {
// TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI.
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(true);
@ -551,11 +730,12 @@ export function CommentThread({
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
<TimelineList
timeline={timeline}
agentMap={agentMap}
currentUserId={currentUserId}
companyId={companyId}
projectId={projectId}
feedbackVoteByTargetId={feedbackVoteByTargetId}

View file

@ -0,0 +1,151 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
import type { IssueAttachment } from "@paperclipai/shared";
interface ImageGalleryModalProps {
images: IssueAttachment[];
initialIndex: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ImageGalleryModal({
images,
initialIndex,
open,
onOpenChange,
}: ImageGalleryModalProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const imageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (open) setCurrentIndex(initialIndex);
}, [open, initialIndex]);
const goNext = useCallback(() => {
setCurrentIndex((i) => (i + 1) % images.length);
}, [images.length]);
const goPrev = useCallback(() => {
setCurrentIndex((i) => (i - 1 + images.length) % images.length);
}, [images.length]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight") goNext();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, goNext, goPrev, onOpenChange]);
/** Close when clicking empty curtain space (not interactive elements or the image) */
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.closest("button") ||
target.closest("a") ||
target === imageRef.current
)
return;
onOpenChange(false);
},
[onOpenChange],
);
if (images.length === 0) return null;
const current = images[currentIndex];
if (!current) return null;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
{/* Full-screen curtain */}
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/90 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex flex-col outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200"
onClick={handleBackdropClick}
>
{/* Top bar */}
<div className="flex items-center justify-between px-5 py-3 text-white/80 text-sm shrink-0">
<span className="truncate max-w-[50%] font-medium" title={current.originalFilename ?? undefined}>
{current.originalFilename ?? "Image"}
</span>
<div className="flex items-center gap-4">
<span className="text-white/40 tabular-nums text-xs">
{currentIndex + 1} / {images.length}
</span>
<a
href={current.contentPath}
download={current.originalFilename ?? "image"}
className="text-white/50 hover:text-white transition-colors"
title="Download"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-4.5 w-4.5" />
</a>
<button
type="button"
onClick={() => onOpenChange(false)}
className="text-white/50 hover:text-white transition-colors"
title="Close"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Main area: nav buttons outside image */}
<div className="flex-1 flex items-center min-h-0">
{/* Left nav zone */}
<div className="w-16 md:w-24 shrink-0 flex items-center justify-center h-full">
{images.length > 1 && (
<button
type="button"
onClick={goPrev}
className="rounded-full bg-white/10 p-3 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
title="Previous"
>
<ChevronLeft className="h-7 w-7" />
</button>
)}
</div>
{/* Image */}
<div className="flex-1 flex items-center justify-center min-w-0 min-h-0 h-full px-2">
<img
ref={imageRef}
src={current.contentPath}
alt={current.originalFilename ?? "attachment"}
className="max-w-full max-h-full object-contain select-none rounded-lg"
draggable={false}
/>
</div>
{/* Right nav zone */}
<div className="w-16 md:w-24 shrink-0 flex items-center justify-center h-full">
{images.length > 1 && (
<button
type="button"
onClick={goNext}
className="rounded-full bg-white/10 p-3 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
title="Next"
>
<ChevronRight className="h-7 w-7" />
</button>
)}
</div>
</div>
{/* Bottom padding for balance */}
<div className="h-6 shrink-0" />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1,354 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDocumentsSection } from "./IssueDocumentsSection";
import { queryKeys } from "../lib/queryKeys";
const mockIssuesApi = vi.hoisted(() => ({
listDocuments: vi.fn(),
listDocumentRevisions: vi.fn(),
restoreDocumentRevision: vi.fn(),
upsertDocument: vi.fn(),
deleteDocument: vi.fn(),
getDocument: vi.fn(),
}));
const markdownEditorMockState = vi.hoisted(() => ({
emitMountEmptyChange: false,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../hooks/useAutosaveIndicator", () => ({
useAutosaveIndicator: () => ({
state: "idle",
markDirty: vi.fn(),
reset: vi.fn(),
runSave: async (save: () => Promise<unknown>) => save(),
}),
}));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ hash: "" }),
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children, className }: { children: string; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("./MarkdownEditor", async () => {
const React = await import("react");
return {
MarkdownEditor: ({ value, onChange, placeholder, contentClassName }: {
value: string;
onChange?: (value: string) => void;
placeholder?: string;
contentClassName?: string;
}) => {
React.useEffect(() => {
if (!markdownEditorMockState.emitMountEmptyChange) return;
onChange?.("");
}, []);
return (
<div className={contentClassName} data-testid="markdown-editor">
{value || placeholder || ""}
</div>
);
},
};
});
vi.mock("@/components/ui/button", () => ({
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
<button type={type} onClick={onClick} {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/input", () => ({
Input: (props: ComponentProps<"input">) => <input {...props} />,
}));
vi.mock("@/components/ui/dropdown-menu", async () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children, onClick, onSelect, disabled }: {
children: React.ReactNode;
onClick?: () => void;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button
type="button"
disabled={disabled}
onClick={() => {
onSelect?.();
onClick?.();
}}
>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuRadioItem: ({ children, onSelect, disabled }: {
children: React.ReactNode;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button type="button" disabled={disabled} onClick={() => onSelect?.()}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function createIssueDocument(overrides: Partial<IssueDocument> = {}): IssueDocument {
return {
id: "document-1",
companyId: "company-1",
issueId: "issue-1",
key: "plan",
title: "Plan",
format: "markdown",
body: "",
latestRevisionId: "revision-4",
latestRevisionNumber: 4,
createdByAgentId: null,
createdByUserId: "user-1",
updatedByAgentId: null,
updatedByUserId: "user-1",
createdAt: new Date("2026-03-31T12:00:00.000Z"),
updatedAt: new Date("2026-03-31T12:05:00.000Z"),
...overrides,
};
}
function createRevision(overrides: Partial<DocumentRevision> = {}): DocumentRevision {
return {
id: "revision-3",
companyId: "company-1",
documentId: "document-1",
issueId: "issue-1",
key: "plan",
revisionNumber: 3,
title: "Plan",
format: "markdown",
body: "Restored plan body",
changeSummary: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-03-31T11:00:00.000Z"),
...overrides,
};
}
function createIssue(): Issue {
return {
id: "issue-1",
identifier: "PAP-807",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Plan rendering",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 807,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
labelIds: [],
planDocument: createIssueDocument(),
documentSummaries: [createIssueDocument()],
legacyPlanDocument: null,
createdAt: new Date("2026-03-31T12:00:00.000Z"),
updatedAt: new Date("2026-03-31T12:05:00.000Z"),
};
}
describe("IssueDocumentsSection", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
window.localStorage.clear();
vi.clearAllMocks();
markdownEditorMockState.emitMountEmptyChange = false;
});
afterEach(() => {
container.remove();
});
it("shows the restored document body immediately after a revision restore", async () => {
const blankLatestDocument = createIssueDocument({
body: "",
latestRevisionId: "revision-4",
latestRevisionNumber: 4,
});
const restoredDocument = createIssueDocument({
body: "Restored plan body",
latestRevisionId: "revision-5",
latestRevisionNumber: 5,
updatedAt: new Date("2026-03-31T12:06:00.000Z"),
});
const pendingDocuments = deferred<IssueDocument[]>();
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockIssuesApi.listDocuments
.mockResolvedValueOnce([blankLatestDocument])
.mockImplementation(() => pendingDocuments.promise);
mockIssuesApi.restoreDocumentRevision.mockResolvedValue(restoredDocument);
queryClient.setQueryData(
queryKeys.issues.documentRevisions(issue.id, "plan"),
[
createRevision({ id: "revision-4", revisionNumber: 4, body: "", createdAt: new Date("2026-03-31T12:05:00.000Z") }),
createRevision(),
],
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
</QueryClientProvider>,
);
});
await flush();
await flush();
expect(container.textContent).not.toContain("Restored plan body");
const revisionButtons = Array.from(container.querySelectorAll("button"));
const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3"));
expect(historicalRevisionButton).toBeTruthy();
await act(async () => {
historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Viewing revision 3");
expect(container.textContent).toContain("Restored plan body");
const restoreButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Restore this revision"));
expect(restoreButton).toBeTruthy();
await act(async () => {
restoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockIssuesApi.restoreDocumentRevision).toHaveBeenCalledWith("issue-1", "plan", "revision-3");
expect(container.textContent).toContain("Restored plan body");
expect(container.textContent).not.toContain("Viewing revision 3");
pendingDocuments.resolve([restoredDocument]);
await flush();
await act(async () => {
root.unmount();
});
queryClient.clear();
});
it("ignores mount-time editor change noise before a document is actively being edited", async () => {
markdownEditorMockState.emitMountEmptyChange = true;
const document = createIssueDocument({
body: "Loaded plan body",
});
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockIssuesApi.listDocuments.mockResolvedValue([document]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
</QueryClientProvider>,
);
});
await flush();
await flush();
expect(container.textContent).toContain("Loaded plan body");
expect(container.textContent).not.toContain("Markdown body");
await act(async () => {
root.unmount();
});
queryClient.clear();
});
});

View file

@ -29,7 +29,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
type DraftState = {
key: string;
@ -106,6 +106,25 @@ function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null)
return draft.body !== doc.body || (doc.title ?? "") !== draft.title;
}
function toDocumentSummary(document: IssueDocument) {
return {
id: document.id,
companyId: document.companyId,
issueId: document.issueId,
key: document.key,
title: document.title,
format: document.format,
latestRevisionId: document.latestRevisionId,
latestRevisionNumber: document.latestRevisionNumber,
createdByAgentId: document.createdByAgentId,
createdByUserId: document.createdByUserId,
updatedByAgentId: document.updatedByAgentId,
updatedByUserId: document.updatedByUserId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
};
}
export function IssueDocumentsSection({
issue,
canDeleteDocuments,
@ -181,6 +200,36 @@ export function IssueDocumentsSection({
});
}, [issue.id, queryClient]);
const syncDocumentCaches = useCallback((document: IssueDocument) => {
queryClient.setQueryData<IssueDocument[] | undefined>(
queryKeys.issues.documents(issue.id),
(current) => {
if (!current) return [document];
const existingIndex = current.findIndex((entry) => entry.key === document.key);
if (existingIndex === -1) return [...current, document];
return current.map((entry, index) => index === existingIndex ? document : entry);
},
);
queryClient.setQueryData<Issue | undefined>(
queryKeys.issues.detail(issue.id),
(current) => {
if (!current) return current;
const nextSummaries = (() => {
const summary = toDocumentSummary(document);
const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key);
if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary];
return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry);
})();
return {
...current,
planDocument: document.key === "plan" ? document : current.planDocument ?? null,
documentSummaries: nextSummaries,
legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null,
};
},
);
}, [issue.id, queryClient]);
const upsertDocument = useMutation({
mutationFn: async (nextDraft: DraftState) =>
issuesApi.upsertDocument(issue.id, nextDraft.key, {
@ -206,7 +255,8 @@ export function IssueDocumentsSection({
const restoreDocumentRevision = useMutation({
mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) =>
issuesApi.restoreDocumentRevision(issue.id, key, revisionId),
onSuccess: (_document, variables) => {
onSuccess: (document, variables) => {
syncDocumentCaches(document);
setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null }));
setDraft((current) => current?.key === variables.key ? null : current);
setDocumentConflict((current) => current?.key === variables.key ? null : current);
@ -369,6 +419,7 @@ export function IssueDocumentsSection({
isNew: false,
};
});
syncDocumentCaches(saved);
invalidateIssueDocuments();
};
@ -408,7 +459,7 @@ export function IssueDocumentsSection({
setError(err instanceof Error ? err.message : "Failed to save document");
return false;
}
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]);
const reloadDocumentFromServer = useCallback((key: string) => {
if (documentConflict?.key !== key) return;
@ -864,7 +915,14 @@ export function IssueDocumentsSection({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end">
{!isHistoricalPreview ? (
<DropdownMenuItem onClick={() => beginEdit(doc.key)}>
<FilePenLine className="h-3.5 w-3.5" />
Edit document
</DropdownMenuItem>
) : null}
{!isHistoricalPreview ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
>
@ -889,13 +947,6 @@ export function IssueDocumentsSection({
{!isFolded ? (
<div
className="mt-3 space-y-3"
onFocusCapture={!isHistoricalPreview
? () => {
if (!activeDraft) {
beginEdit(doc.key);
}
}
: undefined}
onBlurCapture={!isHistoricalPreview
? async (event) => {
if (activeDraft) {
@ -1026,7 +1077,7 @@ export function IssueDocumentsSection({
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
</div>
) : (
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
onChange={(body) => {
@ -1035,13 +1086,7 @@ export function IssueDocumentsSection({
if (current && current.key === doc.key && !current.isNew) {
return { ...current, body };
}
return {
key: doc.key,
title: doc.title ?? "",
body,
baseRevisionId: doc.latestRevisionId,
isNew: false,
};
return current;
});
}}
placeholder="Markdown body"
@ -1052,6 +1097,10 @@ export function IssueDocumentsSection({
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
</div>
)}
</div>
<div className="flex min-h-4 items-center justify-end px-1">

View file

@ -0,0 +1,161 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MarkdownEditor } from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
}));
vi.mock("@mdxeditor/editor", async () => {
const React = await import("react");
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
const MDXEditor = React.forwardRef(function MockMDXEditor(
{
markdown,
placeholder,
onChange,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
const [content, setContent] = React.useState(markdown);
const handle = React.useMemo(() => ({
setMarkdown: (value: string) => setContent(value),
focus: () => {},
}), []);
React.useEffect(() => {
setForwardedRef(forwardedRef, null);
const timer = window.setTimeout(() => {
setForwardedRef(forwardedRef, handle);
if (mdxEditorMockState.emitMountEmptyReset) {
setContent("");
onChange?.("");
}
}, 0);
return () => {
window.clearTimeout(timer);
setForwardedRef(forwardedRef, null);
};
}, []);
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
});
return {
CodeMirrorEditor: () => null,
MDXEditor,
codeBlockPlugin: () => ({}),
codeMirrorPlugin: () => ({}),
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
headingsPlugin: () => ({}),
imagePlugin: () => ({}),
linkDialogPlugin: () => ({}),
linkPlugin: () => ({}),
listsPlugin: () => ({}),
markdownShortcutPlugin: () => ({}),
quotePlugin: () => ({}),
realmPlugin: (plugin: unknown) => plugin,
tablePlugin: () => ({}),
thematicBreakPlugin: () => ({}),
};
});
vi.mock("../lib/mention-deletion", () => ({
mentionDeletionPlugin: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("MarkdownEditor", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
vi.clearAllMocks();
mdxEditorMockState.emitMountEmptyReset = false;
});
it("applies async external value updates once the editor ref becomes ready", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
await act(async () => {
root.unmount();
});
});
it("keeps the external value when the unfocused editor emits an empty mount reset", async () => {
mdxEditorMockState.emitMountEmptyReset = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={handleChange}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
});

View file

@ -199,8 +199,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
onSubmit,
}: MarkdownEditorProps, forwardedRef) {
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<MDXEditorMethods>(null);
const editorRef = useRef<MDXEditorMethods | null>(null);
const latestValueRef = useRef(value);
const latestPropValueRef = useRef(value);
const pendingExternalValueRef = useRef<string | null>(null);
const isFocusedRef = useRef(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
@ -236,7 +239,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
useImperativeHandle(forwardedRef, () => ({
focus: () => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
},
}), []);
@ -263,10 +266,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
);
if (updated !== current) {
latestValueRef.current = updated;
ref.current?.setMarkdown(updated);
editorRef.current?.setMarkdown(updated);
onChange(updated);
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
}
}, 100);
@ -300,10 +303,29 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return all;
}, [hasImageUpload]);
const handleEditorRef = useCallback((instance: MDXEditorMethods | null) => {
editorRef.current = instance;
if (!instance) return;
const pendingValue = pendingExternalValueRef.current;
if (pendingValue !== null && pendingValue !== latestValueRef.current) {
instance.setMarkdown(pendingValue);
latestValueRef.current = pendingValue;
}
pendingExternalValueRef.current = null;
}, []);
latestPropValueRef.current = value;
useEffect(() => {
if (value !== latestValueRef.current) {
ref.current?.setMarkdown(value);
if (!editorRef.current) {
pendingExternalValueRef.current = value;
return;
}
editorRef.current.setMarkdown(value);
latestValueRef.current = value;
pendingExternalValueRef.current = null;
}
}, [value]);
@ -394,7 +416,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
editorRef.current?.setMarkdown(next);
onChange(next);
}
@ -541,12 +563,35 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
dragDepthRef.current = 0;
setIsDragOver(false);
}}
onFocusCapture={() => {
isFocusedRef.current = true;
}}
onBlurCapture={() => {
isFocusedRef.current = false;
}}
>
<MDXEditor
ref={ref}
ref={handleEditorRef}
markdown={value}
placeholder={placeholder}
onChange={(next) => {
const externalValue = latestPropValueRef.current;
if (!isFocusedRef.current) {
if (next === externalValue) {
latestValueRef.current = externalValue;
return;
}
latestValueRef.current = externalValue;
if (editorRef.current) {
editorRef.current.setMarkdown(externalValue);
pendingExternalValueRef.current = null;
} else {
pendingExternalValueRef.current = externalValue;
}
return;
}
latestValueRef.current = next;
onChange(next);
}}

View file

@ -24,7 +24,7 @@ export const defaultCreateValues: CreateConfigValues = {
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 300,
maxTurnsPerRun: 1000,
heartbeatEnabled: false,
intervalSec: 300,
};