mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
[codex] Polish issue composer and long document display (#4420)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Issue comments and documents are the main working surface where operators and agents collaborate > - File drops, markdown editing, and long issue descriptions need to feel predictable because they sit directly in the task execution loop > - The composer had edge cases around drag targets, attachment feedback, image drops, and long markdown content crowding the page > - This pull request polishes the issue composer, hardens markdown editor regressions, and adds a fold curtain for long issue descriptions/documents > - The benefit is a calmer issue detail surface that handles uploads and long work products without hiding state or breaking layout ## What Changed - Scoped issue-composer drag/drop behavior so the composer owns file drops without turning the whole thread into a competing drop target. - Added clearer attachment upload feedback for non-image files and image-drop stability coverage. - Hardened markdown editor and markdown body handling around HTML-like tag regressions. - Added `FoldCurtain` and wired it into issue descriptions and issue documents so long markdown previews can expand/collapse. - Added Storybook coverage for the fold curtain state. ## Verification - `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/MarkdownBody.test.tsx --config ui/vitest.config.ts` passed: 3 files, 75 tests. - `git diff --check public-gh/master..pap-2228-editor-composer-polish -- . ':(exclude)ui/storybook-static'` passed. - Confirmed this PR does not include `pnpm-lock.yaml`. ## Risks - Low-to-medium risk: this changes user-facing composer/drop behavior and long markdown display. - The fold curtain uses DOM measurement and `ResizeObserver`; reviewers should check browser behavior for very long descriptions and documents. - No database migrations. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, with shell, git, Paperclip API, and GitHub CLI tool use in the local Paperclip workspace. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Note: screenshots were not newly captured during branch splitting; the UI states are covered by component tests and a Storybook story. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
8f1cd0474f
commit
77a72e28c2
10 changed files with 839 additions and 54 deletions
145
ui/src/components/FoldCurtain.tsx
Normal file
145
ui/src/components/FoldCurtain.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface FoldCurtainProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Max height (px) when collapsed. Defaults to 420 (desktop) / 320 (< 640px viewport). */
|
||||||
|
collapsedHeight?: number;
|
||||||
|
/** Only curtain when natural height ≥ collapsedHeight + this buffer. */
|
||||||
|
activationBuffer?: number;
|
||||||
|
moreLabel?: string;
|
||||||
|
lessLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 640;
|
||||||
|
const MOBILE_COLLAPSED_HEIGHT = 320;
|
||||||
|
const DEFAULT_COLLAPSED_HEIGHT = 420;
|
||||||
|
const FADE_HEIGHT_PX = 72;
|
||||||
|
const EXPAND_TRANSITION_MS = 220;
|
||||||
|
|
||||||
|
function useResponsiveCollapsedHeight(explicit?: number) {
|
||||||
|
const [height, setHeight] = useState<number>(() => {
|
||||||
|
if (explicit != null) return explicit;
|
||||||
|
if (typeof window === "undefined") return DEFAULT_COLLAPSED_HEIGHT;
|
||||||
|
return window.innerWidth < MOBILE_BREAKPOINT
|
||||||
|
? MOBILE_COLLAPSED_HEIGHT
|
||||||
|
: DEFAULT_COLLAPSED_HEIGHT;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (explicit != null) {
|
||||||
|
setHeight(explicit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const compute = () =>
|
||||||
|
setHeight(
|
||||||
|
window.innerWidth < MOBILE_BREAKPOINT
|
||||||
|
? MOBILE_COLLAPSED_HEIGHT
|
||||||
|
: DEFAULT_COLLAPSED_HEIGHT,
|
||||||
|
);
|
||||||
|
compute();
|
||||||
|
window.addEventListener("resize", compute);
|
||||||
|
return () => window.removeEventListener("resize", compute);
|
||||||
|
}, [explicit]);
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FoldCurtain({
|
||||||
|
children,
|
||||||
|
collapsedHeight: explicitCollapsedHeight,
|
||||||
|
activationBuffer = 120,
|
||||||
|
moreLabel = "Show more",
|
||||||
|
lessLabel = "Show less",
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
}: FoldCurtainProps) {
|
||||||
|
const collapsedHeight = useResponsiveCollapsedHeight(explicitCollapsedHeight);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [naturalHeight, setNaturalHeight] = useState(0);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [hasMeasured, setHasMeasured] = useState(false);
|
||||||
|
const [allowTransition, setAllowTransition] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = contentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
setNaturalHeight(el.scrollHeight);
|
||||||
|
setHasMeasured(true);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldCurtain = hasMeasured && naturalHeight >= collapsedHeight + activationBuffer;
|
||||||
|
const isClipped = shouldCurtain && !expanded;
|
||||||
|
|
||||||
|
const maskStyle = isClipped
|
||||||
|
? {
|
||||||
|
WebkitMaskImage: `linear-gradient(to bottom, black 0, black calc(100% - ${FADE_HEIGHT_PX}px), transparent 100%)`,
|
||||||
|
maskImage: `linear-gradient(to bottom, black 0, black calc(100% - ${FADE_HEIGHT_PX}px), transparent 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("fold-curtain", className)} data-expanded={expanded ? "true" : "false"}>
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className={cn(
|
||||||
|
"fold-curtain__content relative overflow-hidden",
|
||||||
|
allowTransition && "motion-safe:transition-[max-height] motion-reduce:transition-none",
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
maxHeight: isClipped
|
||||||
|
? `${collapsedHeight}px`
|
||||||
|
: shouldCurtain
|
||||||
|
? `${naturalHeight}px`
|
||||||
|
: undefined,
|
||||||
|
transitionDuration: allowTransition ? `${EXPAND_TRANSITION_MS}ms` : undefined,
|
||||||
|
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
...maskStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{shouldCurtain ? (
|
||||||
|
<div className="fold-curtain__toggle mt-2 flex justify-center print:hidden">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => {
|
||||||
|
setAllowTransition(true);
|
||||||
|
setExpanded((v) => !v);
|
||||||
|
}}
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? lessLabel : moreLabel}
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { cn } from "../lib/utils";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||||
|
import { FoldCurtain } from "./FoldCurtain";
|
||||||
|
|
||||||
interface InlineEditorProps {
|
interface InlineEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -16,6 +17,8 @@ interface InlineEditorProps {
|
||||||
onDropFile?: (file: File) => Promise<void>;
|
onDropFile?: (file: File) => Promise<void>;
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
|
/** When true, long display-mode markdown is clipped with a fade curtain that expands on click. */
|
||||||
|
foldable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||||
|
|
@ -51,6 +54,7 @@ export function InlineEditor({
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
onDropFile,
|
onDropFile,
|
||||||
mentions,
|
mentions,
|
||||||
|
foldable = false,
|
||||||
}: InlineEditorProps) {
|
}: InlineEditorProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [multilineEditing, setMultilineEditing] = useState(false);
|
const [multilineEditing, setMultilineEditing] = useState(false);
|
||||||
|
|
@ -282,9 +286,17 @@ export function InlineEditor({
|
||||||
aria-label={placeholder}
|
aria-label={placeholder}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
{foldable ? (
|
||||||
{previewValue}
|
<FoldCurtain>
|
||||||
</MarkdownBody>
|
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
||||||
|
{previewValue}
|
||||||
|
</MarkdownBody>
|
||||||
|
</FoldCurtain>
|
||||||
|
) : (
|
||||||
|
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
||||||
|
{previewValue}
|
||||||
|
</MarkdownBody>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,14 @@ vi.mock("./MarkdownEditor", () => ({
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
fileDropTarget,
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
|
fileDropTarget?: "editor" | "parent";
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: markdownEditorFocusMock,
|
focus: markdownEditorFocusMock,
|
||||||
|
|
@ -85,6 +87,7 @@ vi.mock("./MarkdownEditor", () => ({
|
||||||
aria-label="Issue chat editor"
|
aria-label="Issue chat editor"
|
||||||
data-class-name={className}
|
data-class-name={className}
|
||||||
data-content-class-name={contentClassName}
|
data-content-class-name={contentClassName}
|
||||||
|
data-file-drop-target={fileDropTarget}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
|
|
@ -249,6 +252,21 @@ function createExpiredRequestConfirmationInteraction(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFileDragEvent(type: string, files: File[]) {
|
||||||
|
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
|
||||||
|
dataTransfer: {
|
||||||
|
types: string[];
|
||||||
|
files: File[];
|
||||||
|
dropEffect?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
event.dataTransfer = {
|
||||||
|
types: ["Files"],
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
describe("IssueChatThread", () => {
|
describe("IssueChatThread", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -818,12 +836,210 @@ describe("IssueChatThread", () => {
|
||||||
expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]");
|
expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]");
|
||||||
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
|
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
|
||||||
expect(editor?.dataset.contentClassName).not.toContain("min-h-[72px]");
|
expect(editor?.dataset.contentClassName).not.toContain("min-h-[72px]");
|
||||||
|
expect(editor?.dataset.fileDropTarget).toBe("parent");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows full-composer drop instructions while dragging files over the issue composer", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
imageUploadHandler={async () => "/api/attachments/image/content"}
|
||||||
|
onAttachImage={async () => undefined}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||||
|
expect(composer).not.toBeNull();
|
||||||
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||||
|
expect(fileInput?.getAttribute("accept")).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
composer?.dispatchEvent(createFileDragEvent("dragenter", [
|
||||||
|
new File(["hello"], "notes.txt", { type: "text/plain" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="issue-chat-composer-drop-overlay"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Drop to upload");
|
||||||
|
expect(container.textContent).toContain("Images insert into the reply");
|
||||||
|
expect(container.textContent).toContain("Other files are added to this issue");
|
||||||
|
expect(composer?.className).toContain("border-primary/45");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows non-image attachment upload state in the composer after a drop", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onAttachImage = vi.fn(async (file: File) => ({
|
||||||
|
id: "attachment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
issueCommentId: null,
|
||||||
|
assetId: "asset-1",
|
||||||
|
provider: "local_disk",
|
||||||
|
objectKey: "issues/issue-1/report.pdf",
|
||||||
|
contentPath: "/api/attachments/attachment-1/content",
|
||||||
|
originalFilename: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
byteSize: file.size,
|
||||||
|
sha256: "abc123",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
createdAt: new Date("2026-04-24T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-24T12:00:00.000Z"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
onAttachImage={onAttachImage}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||||
|
const file = new File(["report body"], "report.pdf", { type: "application/pdf" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
composer?.dispatchEvent(createFileDragEvent("drop", [file]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAttachImage).toHaveBeenCalledWith(file);
|
||||||
|
const attachmentList = container.querySelector('[data-testid="issue-chat-composer-attachments"]');
|
||||||
|
expect(attachmentList).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("report.pdf");
|
||||||
|
expect(container.textContent).toContain("Attached to issue");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the outer composer drop overlay when dragging over the reply editor", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
imageUploadHandler={async () => "/api/attachments/image/content"}
|
||||||
|
onAttachImage={async () => undefined}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||||
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||||
|
expect(composer).not.toBeNull();
|
||||||
|
expect(editor).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
editor?.dispatchEvent(createFileDragEvent("dragenter", [
|
||||||
|
new File(["hello"], "notes.txt", { type: "text/plain" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="issue-chat-composer-drop-overlay"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Drop to upload");
|
||||||
|
expect(container.textContent).not.toContain("Drop image to upload");
|
||||||
|
expect(composer?.className).toContain("border-primary/45");
|
||||||
|
|
||||||
|
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||||
|
expect(fileInput?.getAttribute("accept")).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows non-image attachment upload state in the composer after a drop from the editor", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onAttachImage = vi.fn(async (file: File) => ({
|
||||||
|
id: "attachment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
issueCommentId: null,
|
||||||
|
assetId: "asset-1",
|
||||||
|
provider: "local_disk",
|
||||||
|
objectKey: "issues/issue-1/report.pdf",
|
||||||
|
contentPath: "/api/attachments/attachment-1/content",
|
||||||
|
originalFilename: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
byteSize: file.size,
|
||||||
|
sha256: "abc123",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "user-1",
|
||||||
|
createdAt: new Date("2026-04-24T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-24T12:00:00.000Z"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
onAttachImage={onAttachImage}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||||
|
const file = new File(["report body"], "report.pdf", { type: "application/pdf" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editor?.dispatchEvent(createFileDragEvent("drop", [file]));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onAttachImage).toHaveBeenCalledWith(file);
|
||||||
|
const attachmentList = container.querySelector('[data-testid="issue-chat-composer-attachments"]');
|
||||||
|
expect(attachmentList).not.toBeNull();
|
||||||
|
expect(attachmentList?.className).toContain("mb-3");
|
||||||
|
expect(container.textContent).toContain("report.pdf");
|
||||||
|
expect(container.textContent).toContain("Attached to issue");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the bottom spacer with zero height until the user has submitted", () => {
|
it("renders the bottom spacer with zero height until the user has submitted", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import type {
|
||||||
FeedbackDataSharingPreference,
|
FeedbackDataSharingPreference,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
FeedbackVoteValue,
|
FeedbackVoteValue,
|
||||||
|
IssueAttachment,
|
||||||
IssueRelationIssueSummary,
|
IssueRelationIssueSummary,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||||
|
|
@ -219,7 +220,7 @@ export interface IssueChatComposerHandle {
|
||||||
|
|
||||||
interface IssueChatComposerProps {
|
interface IssueChatComposerProps {
|
||||||
onImageUpload?: (file: File) => Promise<string>;
|
onImageUpload?: (file: File) => Promise<string>;
|
||||||
onAttachImage?: (file: File) => Promise<void>;
|
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||||
draftKey?: string;
|
draftKey?: string;
|
||||||
enableReassign?: boolean;
|
enableReassign?: boolean;
|
||||||
reassignOptions?: InlineEntityOption[];
|
reassignOptions?: InlineEntityOption[];
|
||||||
|
|
@ -259,7 +260,7 @@ interface IssueChatThreadProps {
|
||||||
onCancelRun?: () => Promise<void>;
|
onCancelRun?: () => Promise<void>;
|
||||||
onStopRun?: (runId: string) => Promise<void>;
|
onStopRun?: (runId: string) => Promise<void>;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
onAttachImage?: (file: File) => Promise<void>;
|
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||||
draftKey?: string;
|
draftKey?: string;
|
||||||
enableReassign?: boolean;
|
enableReassign?: boolean;
|
||||||
reassignOptions?: InlineEntityOption[];
|
reassignOptions?: InlineEntityOption[];
|
||||||
|
|
@ -508,10 +509,27 @@ const DRAFT_DEBOUNCE_MS = 800;
|
||||||
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||||||
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
|
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
|
||||||
|
|
||||||
|
type ComposerAttachmentItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
status: "uploading" | "attached" | "error";
|
||||||
|
inline: boolean;
|
||||||
|
contentPath?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
|
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
|
||||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAttachmentSize(bytes: number) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return "";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return typeof value === "string" ? value : value.toISOString();
|
return typeof value === "string" ? value : value.toISOString();
|
||||||
|
|
@ -2055,6 +2073,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [composerAttachments, setComposerAttachments] = useState<ComposerAttachmentItem[]>([]);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||||
|
|
@ -2148,6 +2167,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
queueViewportRestore(viewportSnapshot);
|
queueViewportRestore(viewportSnapshot);
|
||||||
await appendPromise;
|
await appendPromise;
|
||||||
if (draftKey) clearDraft(draftKey);
|
if (draftKey) clearDraft(draftKey);
|
||||||
|
setComposerAttachments([]);
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
} catch {
|
} catch {
|
||||||
setBody((current) =>
|
setBody((current) =>
|
||||||
|
|
@ -2163,13 +2183,59 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attachFile(file: File) {
|
async function attachFile(file: File) {
|
||||||
if (onImageUpload && file.type.startsWith("image/")) {
|
const attachmentId = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
||||||
const url = await onImageUpload(file);
|
const inline = Boolean(onImageUpload && file.type.startsWith("image/"));
|
||||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
setComposerAttachments((prev) => [
|
||||||
const markdown = ``;
|
...prev,
|
||||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
{
|
||||||
} else if (onAttachImage) {
|
id: attachmentId,
|
||||||
await onAttachImage(file);
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
status: "uploading",
|
||||||
|
inline,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (onImageUpload && file.type.startsWith("image/")) {
|
||||||
|
const url = await onImageUpload(file);
|
||||||
|
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||||
|
const markdown = ``;
|
||||||
|
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||||
|
setComposerAttachments((prev) => prev.map((item) =>
|
||||||
|
item.id === attachmentId
|
||||||
|
? { ...item, status: "attached", contentPath: url }
|
||||||
|
: item,
|
||||||
|
));
|
||||||
|
} else if (onAttachImage) {
|
||||||
|
const attachment = await onAttachImage(file);
|
||||||
|
setComposerAttachments((prev) => prev.map((item) =>
|
||||||
|
item.id === attachmentId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
status: "attached",
|
||||||
|
contentPath: attachment?.contentPath,
|
||||||
|
name: attachment?.originalFilename ?? item.name,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
setComposerAttachments((prev) => prev.map((item) =>
|
||||||
|
item.id === attachmentId
|
||||||
|
? { ...item, status: "error", error: "This file type cannot be attached here" }
|
||||||
|
: item,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setComposerAttachments((prev) => prev.map((item) =>
|
||||||
|
item.id === attachmentId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Upload failed",
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2202,6 +2268,37 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFileDragEnter(evt: ReactDragEvent<HTMLDivElement>) {
|
||||||
|
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
dragDepthRef.current += 1;
|
||||||
|
setIsDragOver(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDragOver(evt: ReactDragEvent<HTMLDivElement>) {
|
||||||
|
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
evt.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDragLeave(evt: ReactDragEvent<HTMLDivElement>) {
|
||||||
|
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||||
|
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDrop(evt: ReactDragEvent<HTMLDivElement>) {
|
||||||
|
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
resetDragState();
|
||||||
|
void handleDroppedFiles(evt.dataTransfer?.files);
|
||||||
|
}
|
||||||
|
|
||||||
const canSubmit = !submitting && !!body.trim();
|
const canSubmit = !submitting && !!body.trim();
|
||||||
|
|
||||||
if (composerDisabledReason) {
|
if (composerDisabledReason) {
|
||||||
|
|
@ -2217,35 +2314,33 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
ref={composerContainerRef}
|
ref={composerContainerRef}
|
||||||
data-testid="issue-chat-composer"
|
data-testid="issue-chat-composer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
|
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur transition-[border-color,background-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
|
||||||
isDragOver && "ring-2 ring-primary/60 bg-accent/10",
|
isDragOver && "border-primary/45 bg-background shadow-[0_-12px_28px_rgba(15,23,42,0.08),0_0_0_1px_hsl(var(--primary)/0.16)]",
|
||||||
)}
|
)}
|
||||||
onDragEnter={(evt) => {
|
onDragEnterCapture={handleFileDragEnter}
|
||||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
onDragOverCapture={handleFileDragOver}
|
||||||
dragDepthRef.current += 1;
|
onDragLeaveCapture={handleFileDragLeave}
|
||||||
setIsDragOver(true);
|
onDropCapture={handleFileDrop}
|
||||||
}}
|
|
||||||
onDragOver={(evt) => {
|
|
||||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.dataTransfer.dropEffect = "copy";
|
|
||||||
}}
|
|
||||||
onDragLeave={() => {
|
|
||||||
if (!canAcceptFiles) return;
|
|
||||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
||||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
|
||||||
}}
|
|
||||||
onDrop={(evt) => {
|
|
||||||
if (!canAcceptFiles) return;
|
|
||||||
if (evt.defaultPrevented) {
|
|
||||||
resetDragState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
evt.preventDefault();
|
|
||||||
resetDragState();
|
|
||||||
void handleDroppedFiles(evt.dataTransfer?.files);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{isDragOver && canAcceptFiles ? (
|
||||||
|
<div
|
||||||
|
data-testid="issue-chat-composer-drop-overlay"
|
||||||
|
className="pointer-events-none absolute inset-2 z-30 flex items-center justify-center rounded-sm border border-dashed border-primary/55 bg-background/75 px-4 py-3 text-center shadow-sm backdrop-blur-[2px] dark:bg-background/65"
|
||||||
|
>
|
||||||
|
<div className="flex max-w-md items-center gap-3 rounded-md bg-background/80 px-3 py-2 text-left shadow-sm ring-1 ring-border/60">
|
||||||
|
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground">Drop to upload</div>
|
||||||
|
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||||
|
Images insert into the reply. Other files are added to this issue.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={body}
|
value={body}
|
||||||
|
|
@ -2254,8 +2349,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
imageUploadHandler={onImageUpload}
|
imageUploadHandler={onImageUpload}
|
||||||
|
fileDropTarget="parent"
|
||||||
bordered={false}
|
bordered={false}
|
||||||
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
|
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 pb-2 text-sm scrollbar-auto-hide"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{composerHint ? (
|
{composerHint ? (
|
||||||
|
|
@ -2264,13 +2360,57 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{composerAttachments.length > 0 ? (
|
||||||
|
<div
|
||||||
|
data-testid="issue-chat-composer-attachments"
|
||||||
|
className="mb-3 mt-2 space-y-1.5 rounded-md border border-dashed border-border/80 bg-muted/20 p-2"
|
||||||
|
>
|
||||||
|
{composerAttachments.map((attachment) => {
|
||||||
|
const sizeLabel = formatAttachmentSize(attachment.size);
|
||||||
|
const statusLabel =
|
||||||
|
attachment.status === "uploading"
|
||||||
|
? "Uploading to issue"
|
||||||
|
: attachment.status === "error"
|
||||||
|
? attachment.error ?? "Upload failed"
|
||||||
|
: attachment.inline
|
||||||
|
? "Inserted inline"
|
||||||
|
: "Attached to issue";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
|
||||||
|
attachment.status === "error"
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-background/70 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{attachment.status === "uploading" ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||||
|
) : attachment.status === "attached" ? (
|
||||||
|
<Check className="h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
||||||
|
{attachment.name}
|
||||||
|
</span>
|
||||||
|
{sizeLabel ? (
|
||||||
|
<span className="shrink-0 text-muted-foreground">{sizeLabel}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="shrink-0 text-muted-foreground">{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
{(onImageUpload || onAttachImage) ? (
|
{(onImageUpload || onAttachImage) ? (
|
||||||
<div className="mr-auto flex items-center gap-3">
|
<div className="mr-auto flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
ref={attachInputRef}
|
ref={attachInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleAttachFile}
|
onChange={handleAttachFile}
|
||||||
/>
|
/>
|
||||||
|
|
@ -2279,7 +2419,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => attachInputRef.current?.click()}
|
onClick={() => attachInputRef.current?.click()}
|
||||||
disabled={attaching}
|
disabled={attaching}
|
||||||
title="Attach image"
|
title="Attach file"
|
||||||
>
|
>
|
||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||||
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, relativeTime } from "../lib/utils";
|
import { cn, relativeTime } from "../lib/utils";
|
||||||
|
import { FoldCurtain } from "./FoldCurtain";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||||
|
|
@ -70,8 +71,12 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||||
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(body: string, className?: string) {
|
function renderFoldableBody(body: string, className?: string) {
|
||||||
return <MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>;
|
return (
|
||||||
|
<FoldCurtain>
|
||||||
|
<MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>
|
||||||
|
</FoldCurtain>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlanKey(key: string) {
|
function isPlanKey(key: string) {
|
||||||
|
|
@ -780,7 +785,7 @@ export function IssueDocumentsSection({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={documentBodyPaddingClassName}>
|
<div className={documentBodyPaddingClassName}>
|
||||||
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1067,7 +1072,7 @@ export function IssueDocumentsSection({
|
||||||
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
||||||
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
|
{renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1089,7 +1094,7 @@ export function IssueDocumentsSection({
|
||||||
>
|
>
|
||||||
{isHistoricalPreview ? (
|
{isHistoricalPreview ? (
|
||||||
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
|
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
|
||||||
{renderBody(displayedBody, documentBodyContentClassName)}
|
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
|
||||||
</div>
|
</div>
|
||||||
) : activeDraft ? (
|
) : activeDraft ? (
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
|
|
@ -1113,7 +1118,7 @@ export function IssueDocumentsSection({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-border/60 bg-background/40 p-3">
|
<div className="rounded-md border border-border/60 bg-background/40 p-3">
|
||||||
{renderBody(displayedBody, documentBodyContentClassName)}
|
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,20 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).not.toContain("javascript:");
|
expect(html).not.toContain("javascript:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders raw HTML tags as escaped text", () => {
|
||||||
|
const html = renderMarkdown(
|
||||||
|
'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).not.toContain("<script>");
|
||||||
|
expect(html).not.toContain("<iframe");
|
||||||
|
expect(html).not.toContain("<p onclick");
|
||||||
|
expect(html).not.toContain('onclick="steal()"');
|
||||||
|
expect(html).toContain("<script>");
|
||||||
|
expect(html).toContain("onclick="steal()"");
|
||||||
|
expect(html).toContain("Plain text");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses soft-break styling by default", () => {
|
it("uses soft-break styling by default", () => {
|
||||||
const html = renderMarkdown("First line\nSecond line");
|
const html = renderMarkdown("First line\nSecond line");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ const mdxEditorMockState = vi.hoisted(() => ({
|
||||||
suppressHtmlProcessingValues: [] as boolean[],
|
suppressHtmlProcessingValues: [] as boolean[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function containsHtmlLikeTag(markdown: string) {
|
||||||
|
return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?\/?>/.test(markdown);
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock("@mdxeditor/editor", async () => {
|
vi.mock("@mdxeditor/editor", async () => {
|
||||||
const React = await import("react");
|
const React = await import("react");
|
||||||
|
|
||||||
|
|
@ -63,7 +67,7 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!suppressHtmlProcessing && markdown.includes("<img ")) {
|
if (!suppressHtmlProcessing && containsHtmlLikeTag(markdown)) {
|
||||||
setContent("");
|
setContent("");
|
||||||
onError?.({
|
onError?.({
|
||||||
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
|
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
|
||||||
|
|
@ -148,6 +152,17 @@ async function flush() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFileDragEvent(type: string) {
|
||||||
|
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
|
||||||
|
dataTransfer: { types: string[]; files: File[]; dropEffect?: string };
|
||||||
|
};
|
||||||
|
event.dataTransfer = {
|
||||||
|
types: ["Files"],
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
describe("MarkdownEditor", () => {
|
describe("MarkdownEditor", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
||||||
|
|
@ -251,7 +266,7 @@ describe("MarkdownEditor", () => {
|
||||||
await flush();
|
await flush();
|
||||||
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("");
|
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("");
|
||||||
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
||||||
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(false);
|
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
||||||
expect(container.textContent).toContain("Before");
|
expect(container.textContent).toContain("Before");
|
||||||
expect(container.textContent).toContain("After");
|
expect(container.textContent).toContain("After");
|
||||||
|
|
||||||
|
|
@ -260,6 +275,55 @@ describe("MarkdownEditor", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps arbitrary HTML-like tags in the rich editor instead of falling back to raw source", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value={'<section data-source="paste">\n## My take\n\n<p>Benchmark notes</p>\n</section>'}
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
||||||
|
expect(container.querySelector("textarea")).toBeNull();
|
||||||
|
expect(container.textContent).toContain("Benchmark notes");
|
||||||
|
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps scriptable pasted HTML inert in the rich editor", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value={'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>'}
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
||||||
|
expect(container.querySelector("textarea")).toBeNull();
|
||||||
|
expect(container.querySelector("script, iframe, p[onclick]")).toBeNull();
|
||||||
|
expect(container.textContent).toContain('fetch("/api/secrets")');
|
||||||
|
expect(container.textContent).toContain("Plain text");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
|
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
|
||||||
mdxEditorMockState.emitMountParseError = true;
|
mdxEditorMockState.emitMountParseError = true;
|
||||||
const handleChange = vi.fn();
|
const handleChange = vi.fn();
|
||||||
|
|
@ -319,6 +383,101 @@ describe("MarkdownEditor", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the editor-scoped dropzone by default when files are dragged over it", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
imageUploadHandler={async () => "https://example.com/image.png"}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
|
||||||
|
expect(scope).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
scope?.dispatchEvent(createFileDragEvent("dragenter"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scope?.className).toContain("ring-1");
|
||||||
|
expect(container.textContent).toContain("Drop image to upload");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
scope?.dispatchEvent(createFileDragEvent("dragleave"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scope?.className).not.toContain("ring-1");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers file-drop visuals to a parent container when requested", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
imageUploadHandler={async () => "https://example.com/image.png"}
|
||||||
|
fileDropTarget="parent"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
|
||||||
|
expect(scope).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
scope?.dispatchEvent(createFileDragEvent("dragenter"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scope?.className).not.toContain("ring-1");
|
||||||
|
expect(container.textContent).not.toContain("Drop image to upload");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the raw fallback while image-only markdown is settling", async () => {
|
||||||
|
mdxEditorMockState.emitMountSilentEmptyState = true;
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MarkdownEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Markdown body"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.querySelector("textarea")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
||||||
expect(
|
expect(
|
||||||
computeMentionMenuPosition(
|
computeMentionMenuPosition(
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ interface MarkdownEditorProps {
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
|
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
|
||||||
onDropFile?: (file: File) => Promise<void>;
|
onDropFile?: (file: File) => Promise<void>;
|
||||||
|
/** When set to `parent`, a wrapper owns drag/drop behavior and visuals. */
|
||||||
|
fileDropTarget?: "editor" | "parent";
|
||||||
bordered?: boolean;
|
bordered?: boolean;
|
||||||
/** List of mentionable entities. Enables @-mention autocomplete. */
|
/** List of mentionable entities. Enables @-mention autocomplete. */
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
|
|
@ -126,6 +128,10 @@ function hasMeaningfulEditorContent(node: Node | null): boolean {
|
||||||
return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMarkdownImage(value: string): boolean {
|
||||||
|
return /!\[[\s\S]*?\]\([^)]+\)/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
function isRichEditorDomEmpty(
|
function isRichEditorDomEmpty(
|
||||||
editable: HTMLElement,
|
editable: HTMLElement,
|
||||||
expectedValue: string,
|
expectedValue: string,
|
||||||
|
|
@ -133,9 +139,11 @@ function isRichEditorDomEmpty(
|
||||||
): boolean {
|
): boolean {
|
||||||
const expectedText = expectedValue.trim();
|
const expectedText = expectedValue.trim();
|
||||||
if (!expectedText) return false;
|
if (!expectedText) return false;
|
||||||
|
const expectedHasImage = hasMarkdownImage(expectedText);
|
||||||
|
|
||||||
const visibleText = (editable.textContent ?? "").trim();
|
const visibleText = (editable.textContent ?? "").trim();
|
||||||
if (visibleText.length === 0) {
|
if (visibleText.length === 0) {
|
||||||
|
if (expectedHasImage) return false;
|
||||||
return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +153,7 @@ function isRichEditorDomEmpty(
|
||||||
&& visibleText === normalizedPlaceholder
|
&& visibleText === normalizedPlaceholder
|
||||||
&& expectedText !== normalizedPlaceholder
|
&& expectedText !== normalizedPlaceholder
|
||||||
) {
|
) {
|
||||||
|
if (expectedHasImage) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -491,6 +500,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
onBlur,
|
onBlur,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
onDropFile,
|
onDropFile,
|
||||||
|
fileDropTarget = "editor",
|
||||||
bordered = true,
|
bordered = true,
|
||||||
mentions,
|
mentions,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -897,8 +907,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDropImage = Boolean(imageUploadHandler);
|
const canDropFile = fileDropTarget === "editor" && Boolean(imageUploadHandler || onDropFile);
|
||||||
const canDropFile = Boolean(imageUploadHandler || onDropFile);
|
|
||||||
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
const clipboard = event.clipboardData;
|
const clipboard = event.clipboardData;
|
||||||
if (!clipboard || !ref.current) return;
|
if (!clipboard || !ref.current) return;
|
||||||
|
|
@ -1082,6 +1091,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
<MDXEditor
|
<MDXEditor
|
||||||
ref={setEditorRef}
|
ref={setEditorRef}
|
||||||
markdown={editorValue}
|
markdown={editorValue}
|
||||||
|
suppressHtmlProcessing
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
|
|
|
||||||
|
|
@ -578,7 +578,7 @@ type IssueDetailChatTabProps = {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||||
onImageUpload: (file: File) => Promise<string>;
|
onImageUpload: (file: File) => Promise<string>;
|
||||||
onAttachImage: (file: File) => Promise<void>;
|
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
|
||||||
onInterruptQueued: (runId: string) => Promise<void>;
|
onInterruptQueued: (runId: string) => Promise<void>;
|
||||||
onCancelQueued: (commentId: string) => void;
|
onCancelQueued: (commentId: string) => void;
|
||||||
interruptingQueuedRunId: string | null;
|
interruptingQueuedRunId: string | null;
|
||||||
|
|
@ -2543,7 +2543,7 @@ export function IssueDetail() {
|
||||||
return attachment.contentPath;
|
return attachment.contentPath;
|
||||||
}, [uploadAttachment]);
|
}, [uploadAttachment]);
|
||||||
const handleCommentAttachImage = useCallback(async (file: File) => {
|
const handleCommentAttachImage = useCallback(async (file: File) => {
|
||||||
await uploadAttachment.mutateAsync(file);
|
return uploadAttachment.mutateAsync(file);
|
||||||
}, [uploadAttachment]);
|
}, [uploadAttachment]);
|
||||||
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
|
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
|
||||||
await interruptQueuedComment.mutateAsync(runId);
|
await interruptQueuedComment.mutateAsync(runId);
|
||||||
|
|
@ -3069,6 +3069,7 @@ export function IssueDetail() {
|
||||||
className="text-[15px] leading-7 text-foreground"
|
className="text-[15px] leading-7 text-foreground"
|
||||||
placeholder="Add a description..."
|
placeholder="Add a description..."
|
||||||
multiline
|
multiline
|
||||||
|
foldable
|
||||||
mentions={mentionOptions}
|
mentions={mentionOptions}
|
||||||
imageUploadHandler={async (file) => {
|
imageUploadHandler={async (file) => {
|
||||||
const attachment = await uploadAttachment.mutateAsync(file);
|
const attachment = await uploadAttachment.mutateAsync(file);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { Agent, CompanySecret, EnvBinding, Project, RoutineVariable } from
|
||||||
import { Code2, FileText, ListPlus, RotateCcw, Table2 } from "lucide-react";
|
import { Code2, FileText, ListPlus, RotateCcw, Table2 } from "lucide-react";
|
||||||
import { EnvVarEditor } from "@/components/EnvVarEditor";
|
import { EnvVarEditor } from "@/components/EnvVarEditor";
|
||||||
import { ExecutionParticipantPicker } from "@/components/ExecutionParticipantPicker";
|
import { ExecutionParticipantPicker } from "@/components/ExecutionParticipantPicker";
|
||||||
|
import { FoldCurtain } from "@/components/FoldCurtain";
|
||||||
import { InlineEditor } from "@/components/InlineEditor";
|
import { InlineEditor } from "@/components/InlineEditor";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||||
import { JsonSchemaForm, type JsonSchemaNode, getDefaultValues } from "@/components/JsonSchemaForm";
|
import { JsonSchemaForm, type JsonSchemaNode, getDefaultValues } from "@/components/JsonSchemaForm";
|
||||||
|
|
@ -710,3 +711,85 @@ export const RoutineRunVariablesDialogOpen: Story = {
|
||||||
name: "Routine Run Variables Dialog",
|
name: "Routine Run Variables Dialog",
|
||||||
render: () => <RoutineRunDialogStory />,
|
render: () => <RoutineRunDialogStory />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const foldCurtainLongMarkdown = [
|
||||||
|
"# paperclip-bench",
|
||||||
|
"",
|
||||||
|
"Ship criteria for the benchmark harness — these notes are intentionally lengthy so the fold-curtain clips them.",
|
||||||
|
"",
|
||||||
|
"## Overview",
|
||||||
|
"",
|
||||||
|
"We need a benchmark that compares agent performance across task types and model backends. This includes:",
|
||||||
|
"",
|
||||||
|
"- a **runner** that executes tasks in isolated workspaces",
|
||||||
|
"- a **scorer** that grades outputs against ground truth",
|
||||||
|
"- a **dashboard** that trends metrics over time",
|
||||||
|
"",
|
||||||
|
"## Task format",
|
||||||
|
"",
|
||||||
|
"Each task is a directory containing a `task.md`, an optional `setup.sh`, and an `expected/` fixture. The runner mounts the task, executes the agent, and diffs the resulting workspace against `expected/`.",
|
||||||
|
"",
|
||||||
|
"```ts",
|
||||||
|
"type TaskResult = {",
|
||||||
|
" taskId: string;",
|
||||||
|
" agent: string;",
|
||||||
|
" exitCode: number;",
|
||||||
|
" scoreBreakdown: Record<string, number>;",
|
||||||
|
"};",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"## Metrics",
|
||||||
|
"",
|
||||||
|
"| Metric | Description |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| Pass@1 | First-try correctness |",
|
||||||
|
"| Tokens | Cost per task |",
|
||||||
|
"| Wall time | End-to-end minutes |",
|
||||||
|
"",
|
||||||
|
"## Next steps",
|
||||||
|
"",
|
||||||
|
"1. Land the runner with support for 3 task types.",
|
||||||
|
"2. Backfill 50 tasks from open-source benchmarks.",
|
||||||
|
"3. Wire the scorer to GitHub Actions.",
|
||||||
|
"4. Publish baseline numbers on the main branch.",
|
||||||
|
"",
|
||||||
|
"All of this is described in more detail in the design doc linked from the home page.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const foldCurtainShortMarkdown = "This description is short. No curtain should appear.";
|
||||||
|
|
||||||
|
function FoldCurtainStory() {
|
||||||
|
return (
|
||||||
|
<StoryShell>
|
||||||
|
<Section
|
||||||
|
eyebrow="Presentation"
|
||||||
|
title="FoldCurtain"
|
||||||
|
description="Long content collapses to a preview with a bottom fade and a Show more button. Short content renders untouched."
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StatePanel
|
||||||
|
label="Long description (collapsed)"
|
||||||
|
detail="Default state on every fresh page load. Natural height far exceeds the collapsed height, so the curtain activates."
|
||||||
|
>
|
||||||
|
<FoldCurtain>
|
||||||
|
<MarkdownBody className="text-[15px] leading-7">{foldCurtainLongMarkdown}</MarkdownBody>
|
||||||
|
</FoldCurtain>
|
||||||
|
</StatePanel>
|
||||||
|
<StatePanel
|
||||||
|
label="Short description (no curtain)"
|
||||||
|
detail="Content below the activation threshold renders with no curtain and no button."
|
||||||
|
>
|
||||||
|
<FoldCurtain>
|
||||||
|
<MarkdownBody className="text-[15px] leading-7">{foldCurtainShortMarkdown}</MarkdownBody>
|
||||||
|
</FoldCurtain>
|
||||||
|
</StatePanel>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</StoryShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FoldCurtainShowcase: Story = {
|
||||||
|
name: "Fold Curtain",
|
||||||
|
render: () => <FoldCurtainStory />,
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue