mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
fix(ui): polish issue detail timelines and attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
36376968af
commit
bd6d07d0b4
25 changed files with 2020 additions and 82 deletions
123
ui/src/components/CommentThread.test.tsx
Normal file
123
ui/src/components/CommentThread.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 & 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}
|
||||
|
|
|
|||
151
ui/src/components/ImageGalleryModal.tsx
Normal file
151
ui/src/components/ImageGalleryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
ui/src/components/IssueDocumentsSection.test.tsx
Normal file
354
ui/src/components/IssueDocumentsSection.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
161
ui/src/components/MarkdownEditor.test.tsx
Normal file
161
ui/src/components/MarkdownEditor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
|||
workspaceBranchTemplate: "",
|
||||
worktreeParentDir: "",
|
||||
runtimeServicesJson: "",
|
||||
maxTurnsPerRun: 300,
|
||||
maxTurnsPerRun: 1000,
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 300,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue