paperclip/ui/src/components/FoldCurtain.tsx
Dotta 77a72e28c2
[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>
2026-04-24 14:12:41 -05:00

145 lines
4.4 KiB
TypeScript

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>
);
}