From ef19cee18dd4e9edf24a5d0b1005c39b44503d97 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 13:37:54 -0500 Subject: [PATCH] Add fold curtain for long issue documents Co-Authored-By: Paperclip --- ui/src/components/FoldCurtain.tsx | 144 ++++++++++++++++++ ui/src/components/InlineEditor.tsx | 18 ++- ui/src/components/IssueDocumentsSection.tsx | 17 ++- ui/src/pages/IssueDetail.tsx | 1 + .../stories/forms-editors.stories.tsx | 83 ++++++++++ 5 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 ui/src/components/FoldCurtain.tsx diff --git a/ui/src/components/FoldCurtain.tsx b/ui/src/components/FoldCurtain.tsx new file mode 100644 index 00000000000..4cdbfd27c22 --- /dev/null +++ b/ui/src/components/FoldCurtain.tsx @@ -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(() => { + 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(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 ( +
+
+ {children} +
+ {shouldCurtain ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index f866eecdc03..192e8260d8e 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -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; 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} > - - {previewValue} - + {foldable ? ( + + + {previewValue} + + + ) : ( + + {previewValue} + + )} ); } diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 0bb66002634..74a40656f53 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -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 {body}; +function renderFoldableBody(body: string, className?: string) { + return ( + + {body} + + ); } function isPlanKey(key: string) { @@ -780,7 +785,7 @@ export function IssueDocumentsSection({
- {renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)} + {renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
) : null} @@ -1067,7 +1072,7 @@ export function IssueDocumentsSection({ {!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (

{activeConflict.serverDocument.title}

) : null} - {renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")} + {renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")} )} @@ -1089,7 +1094,7 @@ export function IssueDocumentsSection({ > {isHistoricalPreview ? (
- {renderBody(displayedBody, documentBodyContentClassName)} + {renderFoldableBody(displayedBody, documentBodyContentClassName)}
) : activeDraft ? ( ) : (
- {renderBody(displayedBody, documentBodyContentClassName)} + {renderFoldableBody(displayedBody, documentBodyContentClassName)}
)} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 45f13050d92..89e2ef165b6 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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); diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx index b73a2769010..c8e49573ac8 100644 --- a/ui/storybook/stories/forms-editors.stories.tsx +++ b/ui/storybook/stories/forms-editors.stories.tsx @@ -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: () => , }; + +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;", + "};", + "```", + "", + "## 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 ( + +
+
+ + + {foldCurtainLongMarkdown} + + + + + {foldCurtainShortMarkdown} + + +
+
+
+ ); +} + +export const FoldCurtainShowcase: Story = { + name: "Fold Curtain", + render: () => , +};