Add fold curtain for long issue documents

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-24 13:39:29 -05:00
1 parent ba67979df4
commit ef19cee18d
5 files changed
+254 -9

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
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();
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>
);
}
+15 -3
View File
@@ -3,6 +3,7 @@ import { cn } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { FoldCurtain } from "./FoldCurtain";
interface InlineEditorProps {
value: string;
@@ -16,6 +17,8 @@ interface InlineEditorProps {
onDropFile?: (file: File) => Promise<void>;
mentions?: MentionOption[];
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. */
@@ -51,6 +54,7 @@ export function InlineEditor({
imageUploadHandler,
onDropFile,
mentions,
foldable = false,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [multilineEditing, setMultilineEditing] = useState(false);
@@ -282,9 +286,17 @@ export function InlineEditor({
aria-label={placeholder}
tabIndex={0}
>
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{previewValue}
</MarkdownBody>
{foldable ? (
<FoldCurtain>
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{previewValue}
</MarkdownBody>
</FoldCurtain>
) : (
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{previewValue}
</MarkdownBody>
)}
</div>
);
}
+11 -6
View File
@@ -16,6 +16,7 @@ import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { deriveDocumentRevisionState } from "../lib/document-revisions";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
@@ -70,8 +71,12 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
}
function renderBody(body: string, className?: string) {
return <MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>;
function renderFoldableBody(body: string, className?: string) {
return (
<FoldCurtain>
<MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>
</FoldCurtain>
);
}
function isPlanKey(key: string) {
@@ -780,7 +785,7 @@ export function IssueDocumentsSection({
</span>
</div>
<div className={documentBodyPaddingClassName}>
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
</div>
</div>
) : null}
@@ -1067,7 +1072,7 @@ export function IssueDocumentsSection({
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
) : null}
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
{renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
</div>
)}
</div>
@@ -1089,7 +1094,7 @@ export function IssueDocumentsSection({
>
{isHistoricalPreview ? (
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
</div>
) : activeDraft ? (
<MarkdownEditor
@@ -1113,7 +1118,7 @@ export function IssueDocumentsSection({
/>
) : (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
</div>
)}
</div>
+1
View File
@@ -3069,6 +3069,7 @@ export function IssueDetail() {
className="text-[15px] leading-7 text-foreground"
placeholder="Add a description..."
multiline
foldable
mentions={mentionOptions}
imageUploadHandler={async (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 { EnvVarEditor } from "@/components/EnvVarEditor";
import { ExecutionParticipantPicker } from "@/components/ExecutionParticipantPicker";
import { FoldCurtain } from "@/components/FoldCurtain";
import { InlineEditor } from "@/components/InlineEditor";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import { JsonSchemaForm, type JsonSchemaNode, getDefaultValues } from "@/components/JsonSchemaForm";
@@ -710,3 +711,85 @@ export const RoutineRunVariablesDialogOpen: Story = {
name: "Routine Run Variables Dialog",
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 />,
};