mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Add fold curtain for long issue documents
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ba67979df4
commit
ef19cee18d
5 files changed
+254
-9
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />,
|
||||
};
|
||||
Reference in new issue
Block a user