mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue