Files
PaperClipAI/ui/src/components/PropertiesPanel.tsx
T
DottaandPaperclip e71a23c01c fix(ui): animate late task sidebar loading (#17880)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/61/changes/paperclip/61
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task pages load the conversation and related sidebar data
separately.
> - The sidebar host did not exist until its content arrived.
> - Its first appearance skipped the width transition and moved the
thread at once.
> - This pull request keeps an empty host at zero width until content is
available.
> - The sidebar and thread now move together, with immediate changes for
reduced motion.

## Linked Issues or Issue Description

**What happened?**
A cold task load showed a wide skeleton. The right sidebar then appeared
at full width and shifted the thread at once.

**Expected behavior**
The sidebar should enter smoothly. The task thread should move with it.
Unknown or hidden sidebars should take no space.

**Steps to reproduce**
1. Open a populated task in a fresh browser context.
2. Delay the task metadata response.
3. Watch the skeleton and thread as the right sidebar becomes available.

Related work: [task cold-open
reads](https://github.com/paperclipai/paperclip/pull/17756).

## What Changed

- Keep the shared sidebar host mounted at zero width while its content
is unknown. Make hidden hosts inert and hide them from assistive
technology.
- Use shared motion tokens for width and opacity. Disable motion for
reduced-motion preferences and keep pointer resizing immediate.
- Clamp late content to the current viewport without overwriting the
saved width. Keep the motion catalog aligned with the reveal tokens.
- Clip the panel body during reveal. Keep the resize handle inside the
viewport during the first frame.
- Clear empty panes immediately when content is removed. Release
maximized geometry on that render. Remove the unused width/opacity
token.
- Add four replayable Storybook scenarios using the production skeleton
and panel components. Add browser checks for both arrival orders, absent
and hidden sidebars, reduced motion, resizing, and maximize/restore.
- Document the shared sidebar loading behavior in DESIGN.md.
- Load the comment-route test modules in suite setup. This prevents a
slow module load from timing out the first test and affecting the next
test. This test-only change fixes a reproducible CI failure; route
behavior and assertions stay the same.

## Verification

- 201 focused task-detail, properties-panel, and motion-catalog tests
passed. New host-continuity regressions failed against the original
component in both task interfaces.
- Eight focused Storybook browser checks passed without retries after
the review fix. They measure intermediate widths and matching thread
widths, check overflow, exercise resize and maximize controls, and
confirm immediate removal of docked and maximized content.
- The first removal checks sampled before replay cleanup committed.
Sampling now starts after the host becomes inert. All three removal
checks fail against the prior commit. Restoring the source during that
comparison left a stale Storybook module in the preview. After a preview
restart, all eight cases passed without retries on the fixed source.
- Verified a real populated local task in a fresh browser context with
delayed real API responses. Recorded a screencast for visual review. The
captured reveal had 20 intermediate widths, no horizontal overflow, and
no browser errors.
- Repository typecheck, full build, Storybook build, token gates, map
checks, and 30 catalog/map tests passed. Full typecheck, full build,
focused UI tests, and UI typecheck were repeated after the upstream
rebase.
- Full `pnpm test:run` was attempted and stopped after repeated embedded
PostgreSQL initialization failures. The host has exhausted its semaphore
pool. The local app test used a disposable container database instead.
- Replay `tasks-sidebar-loading--sidebar-during-skeleton` in Storybook.
Run the focused browser suite with `STORYBOOK_TEST_BASE_URL` set to that
Storybook server.

- CI serialized shard 6/9 failed when the first comment-route test hit
its 15-second timeout. Its work then affected the next test's mock
count. Both failures reproduced locally on Node 24. After moving module
loading to suite setup, all 103 tests and server typecheck passed with
the same test timeout. A first local attempt could not bind the
Supertest listener inside the sandbox; it was repeated with local
network access.

## Risks

- The shared host also serves other properties panels. Hidden
preferences, default widths, pointer resizing, maximize/restore, and
both task interfaces have focused coverage.
- Width animation can reflow text while the panel enters. Reduced motion
removes the transition. Navigation removes empty panes immediately.
- Visual approval is complete. The full CI suite must pass before merge.
No schema or API contracts change.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, code execution, and
browser tools. The exact deployed model ID and context-window size are
not exposed in this session.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-27 10:10:45 -05:00

474 lines
18 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { X } from "lucide-react";
import { usePanel } from "../context/PanelContext";
import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SidePanelFrame, SidePanelWindowControls } from "@/components/side-panel";
import { useDesignSandbox } from "@/context/DesignSandboxContext";
export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
const {
panelContent,
panelContentMode,
panelVisible,
setPanelVisible,
panelMaximizeRequested,
clearPanelMaximizeRequest,
} = usePanel();
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const designSandbox = useDesignSandbox();
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
// Keep the zero-width host mounted while route data loads. Inserting an
// already-open pane skips its width transition and snaps the thread sideways.
const visible = panelVisible && panelContent != null;
if (classicTaskInterfaceEnabled) {
return (
<aside
inert={!visible}
aria-hidden={!visible}
className={cn("hidden md:flex border-border bg-card flex-col shrink-0 overflow-hidden h-full", panelContent != null && "properties-panel-docked", visible && "border-l")}
style={{ width: visible ? 320 : 0, opacity: visible ? 1 : 0 }}
>
<div className="w-80 flex-1 flex flex-col min-w-(--sz-320px) min-h-0">
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-sm font-medium">Properties</span>
<Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1">
<div className="p-4">{panelContent}</div>
</ScrollArea>
</div>
</aside>
);
}
return (
<ResizablePropertiesPanel
key={`${streamlinedTaskDetailLayout ? "task-detail" : "default"}-${designSandbox ? "proposal" : "production"}`}
panelContent={panelContent}
panelContentMode={panelContentMode}
panelVisible={visible}
setPanelVisible={setPanelVisible}
taskDetailLayout={streamlinedTaskDetailLayout}
designSandbox={designSandbox}
maximizeRequested={panelMaximizeRequested}
clearMaximizeRequest={clearPanelMaximizeRequest}
/>
);
}
/* ------------------------------------------------------------------------- *
* Production chat-style resizable/maximizable variant. Streamlined UI only
* changes its task-detail dimensions; Classic Task Interface selects the
* fixed panel above.
* ------------------------------------------------------------------------- */
/**
* Portal target in the redesigned pane's header bar: hosted content (the
* Properties | Plan | Artifacts tab strip, including the single active
* Properties tab) renders here, left of the window controls. See
* IssueProperties' flag-ON shell.
*/
export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot";
/**
* Portal target pinned below the pane's scroll area: hosted content (the plan
* confirmation action bar) renders here so it stays visible while the pane
* body scrolls.
*/
export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot";
const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth";
const TASK_DETAIL_WIDTH_STORAGE_KEY = "taskChatRedesign.taskDetailPropertiesPaneWidth";
const PROPOSAL_WIDTH_STORAGE_KEY = "paperclip.design-proposal.properties-pane-width";
const DEFAULT_PANE_WIDTH = 322;
const TASK_DETAIL_DEFAULT_PANE_WIDTH = 434;
const MIN_PANE_WIDTH = 260;
/** ~236px sidebar + ~420px minimum center column stay usable while resizing. */
const RESERVED_LAYOUT_WIDTH = 656;
/** Content cap while maximized so text doesn't span the full viewport. */
const MAXIMIZED_CONTENT_MAX_WIDTH = 840;
/**
* Defensive fallback (in milliseconds) for the restore glide in case
* `transitionend` never fires; slightly longer than the --motion-pane-glide
* token in index.css.
*/
const RESTORE_FALLBACK_DELAY = 400;
function readProposalPaneWidth(fallback: number): number {
if (typeof window === "undefined") return fallback;
const tokenWidth = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--design-page-aside-width"));
return Number.isFinite(tokenWidth) && tokenWidth > 0 ? tokenWidth : fallback;
}
function clampPaneWidth(width: number): number {
const max =
typeof window === "undefined"
? Number.POSITIVE_INFINITY
: Math.max(MIN_PANE_WIDTH, window.innerWidth - RESERVED_LAYOUT_WIDTH);
return Math.min(Math.max(Math.round(width), MIN_PANE_WIDTH), max);
}
function readStoredPaneWidth(storageKey: string, defaultWidth: number): number {
if (typeof window === "undefined") return defaultWidth;
try {
const raw = window.localStorage.getItem(storageKey);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : defaultWidth;
} catch {
return defaultWidth;
}
}
function persistPaneWidth(storageKey: string, width: number) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, String(width));
} catch {
// Ignore storage failures.
}
}
function clearStoredPaneWidth(storageKey: string) {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(storageKey);
} catch {
// Ignore storage failures.
}
}
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/** Fixed-position geometry while the panel is maximized (or gliding). */
interface FixedPane {
top: number;
/** Animated by the .tc-pane-glide transition. */
left: number;
/** Distance from the viewport's right edge to the panel's right edge. */
rightInset: number;
/** Glide target: the layout row's left edge (flush with the sidebar). */
parentLeft: number;
}
interface ResizablePropertiesPanelProps {
panelContent: ReactNode | null;
panelContentMode: "padded" | "prose" | "full-bleed";
panelVisible: boolean;
setPanelVisible: (visible: boolean) => void;
taskDetailLayout: boolean;
designSandbox: boolean;
/** Pending `viewer=full` deep-link request (LOOA-2181); cleared once consumed. */
maximizeRequested: boolean;
clearMaximizeRequest: () => void;
}
function ResizablePropertiesPanel({
panelContent,
panelContentMode,
panelVisible,
setPanelVisible,
taskDetailLayout,
designSandbox,
maximizeRequested,
clearMaximizeRequest,
}: ResizablePropertiesPanelProps) {
const productionPaneWidth = taskDetailLayout
? TASK_DETAIL_DEFAULT_PANE_WIDTH
: DEFAULT_PANE_WIDTH;
const defaultPaneWidth = designSandbox ? readProposalPaneWidth(productionPaneWidth) : productionPaneWidth;
const widthStorageKey = designSandbox
? `${PROPOSAL_WIDTH_STORAGE_KEY}.${taskDetailLayout ? "task-detail" : "default"}`
: taskDetailLayout
? TASK_DETAIL_WIDTH_STORAGE_KEY
: WIDTH_STORAGE_KEY;
const [width, setWidth] = useState(() =>
clampPaneWidth(readStoredPaneWidth(widthStorageKey, defaultPaneWidth)),
);
const [dragging, setDragging] = useState(false);
const [maximized, setMaximized] = useState(false);
const [fixedPane, setFixedPane] = useState<FixedPane | null>(null);
// The host can outlive a viewport change while its content is loading.
// Clamp before revealing it, without overwriting the user's saved width.
useLayoutEffect(() => {
if (panelVisible) setWidth(clampPaneWidth);
}, [panelVisible]);
const asideRef = useRef<HTMLElement | null>(null);
const widthRef = useRef(width);
widthRef.current = width;
const dragStateRef = useRef<{ pointerId: number; startX: number; startWidth: number } | null>(
null,
);
const previousBodyUserSelectRef = useRef("");
const restoreTimerRef = useRef<number | null>(null);
const clearRestoreTimer = useCallback(() => {
if (restoreTimerRef.current !== null) {
window.clearTimeout(restoreTimerRef.current);
restoreTimerRef.current = null;
}
}, []);
const finishRestore = useCallback(() => {
clearRestoreTimer();
setFixedPane(null);
}, [clearRestoreTimer]);
// Hiding the panel keeps today's collapse-to-0 behavior; if it was
// maximized (or mid-glide), just unmaximize instantly first.
useEffect(() => {
if (!panelVisible) {
setMaximized(false);
finishRestore();
}
}, [panelVisible, finishRestore]);
useEffect(
() => () => {
if (restoreTimerRef.current !== null) window.clearTimeout(restoreTimerRef.current);
if (dragStateRef.current !== null) {
document.body.style.userSelect = previousBodyUserSelectRef.current;
}
},
[],
);
const endDrag = useCallback((persist: boolean) => {
if (dragStateRef.current === null) return;
dragStateRef.current = null;
setDragging(false);
document.body.style.userSelect = previousBodyUserSelectRef.current;
if (persist) persistPaneWidth(widthStorageKey, widthRef.current);
}, [widthStorageKey]);
const handleGripPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
// Primary button only (touch/pen report button 0 or -1 for down events).
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: widthRef.current,
};
previousBodyUserSelectRef.current = document.body.style.userSelect;
document.body.style.userSelect = "none";
setDragging(true);
}, []);
const handleGripPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (drag === null || drag.pointerId !== event.pointerId) return;
// The grip sits on the panel's LEFT border: moving left widens the panel.
setWidth(clampPaneWidth(drag.startWidth + (drag.startX - event.clientX)));
}, []);
const handleGripPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (drag === null || drag.pointerId !== event.pointerId) return;
endDrag(true);
},
[endDrag],
);
const handleGripLostPointerCapture = useCallback(() => {
endDrag(true);
}, [endDrag]);
const handleGripDoubleClick = useCallback(() => {
setWidth(designSandbox ? clampPaneWidth(readProposalPaneWidth(productionPaneWidth)) : defaultPaneWidth);
clearStoredPaneWidth(widthStorageKey);
}, [defaultPaneWidth, designSandbox, productionPaneWidth, widthStorageKey]);
const handleMaximize = useCallback(() => {
const aside = asideRef.current;
const row = aside?.parentElement;
if (!aside || !row) return;
clearRestoreTimer();
setMaximized(true);
const rowRect = row.getBoundingClientRect();
setFixedPane((pane) => {
// Re-maximizing mid-restore: keep the current geometry, glide back left.
if (pane) return { ...pane, left: pane.parentLeft };
const rect = aside.getBoundingClientRect();
const seeded: FixedPane = {
top: rect.top,
left: rect.left,
rightInset: Math.max(0, window.innerWidth - rect.right),
parentLeft: rowRect.left,
};
if (prefersReducedMotion()) return { ...seeded, left: seeded.parentLeft };
// Seed at the current left, then glide to the row's left edge once the
// fixed position has been committed (double rAF).
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
setFixedPane((current) => (current ? { ...current, left: current.parentLeft } : current));
});
});
return seeded;
});
}, [clearRestoreTimer]);
const handleRestore = useCallback(() => {
const row = asideRef.current?.parentElement;
setMaximized(false);
if (prefersReducedMotion()) {
finishRestore();
return;
}
setFixedPane((pane) => {
if (!pane) return pane;
const rowRight = row
? row.getBoundingClientRect().right
: window.innerWidth - pane.rightInset;
return { ...pane, left: rowRight - widthRef.current };
});
clearRestoreTimer();
restoreTimerRef.current = window.setTimeout(finishRestore, RESTORE_FALLBACK_DELAY);
}, [clearRestoreTimer, finishRestore]);
// Deep-link maximize (LOOA-2181): the request may predate this mount (the
// hash routes before the panel content commits), so it lives in context and
// is consumed here once the panel is actually visible and laid out —
// handleMaximize measures live geometry, which needs a committed DOM.
useEffect(() => {
if (!maximizeRequested || !panelVisible) return;
clearMaximizeRequest();
if (!maximized) handleMaximize();
}, [maximizeRequested, panelVisible, maximized, handleMaximize, clearMaximizeRequest]);
const handleTransitionEnd = useCallback(
(event: React.TransitionEvent<HTMLElement>) => {
if (event.target !== asideRef.current || event.propertyName !== "left") return;
// Only the restore glide needs to unfix on arrival.
if (!maximized) finishRestore();
},
[maximized, finishRestore],
);
// Route cleanup removes content before the maximize reset effect runs.
// Release fixed geometry immediately so an empty pane cannot cover the next page.
const isFixed = panelContent != null && fixedPane !== null;
return (
<>
{isFixed ? (
// Holds the panel's slot in the layout flex row while the panel is
// position:fixed, so the main column never reflows.
<div
aria-hidden
className="hidden md:block shrink-0"
style={{ width: panelVisible ? width : 0 }}
/>
) : null}
<aside
ref={asideRef}
inert={!panelVisible}
aria-hidden={!panelVisible}
className={cn(
"hidden md:flex bg-card flex-col",
isFixed
? "tc-pane-glide fixed z-40 overflow-hidden"
: cn(
"relative h-full shrink-0",
panelVisible ? "overflow-visible" : "overflow-hidden",
// The width/opacity transition would fight pointer-driven
// resizing, so it is suspended while dragging.
!dragging && panelContent != null && "properties-panel-docked",
),
)}
style={
isFixed
? {
top: fixedPane.top,
bottom: 0,
left: fixedPane.left,
right: fixedPane.rightInset,
}
: { width: panelVisible ? width : 0, opacity: panelVisible ? 1 : 0 }
}
onTransitionEnd={handleTransitionEnd}
>
{!isFixed && panelVisible ? (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize panel"
data-dragging={dragging ? "" : undefined}
className="pane-resize-handle group absolute inset-y-0 z-10 max-w-full cursor-col-resize touch-none"
style={{ left: -4, width: 8 }}
onPointerDown={handleGripPointerDown}
onPointerMove={handleGripPointerMove}
onPointerUp={handleGripPointerUp}
onPointerCancel={handleGripPointerUp}
onLostPointerCapture={handleGripLostPointerCapture}
onDoubleClick={handleGripDoubleClick}
>
<div
className={cn(
"mx-auto h-full w-0.5 transition-colors",
dragging ? "bg-ring" : "bg-transparent group-hover:bg-ring",
)}
/>
</div>
) : null}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div
className={cn("flex shrink-0 flex-col min-h-0", !maximized && "border-l border-border", isFixed && "w-full")}
style={isFixed ? undefined : { width, minWidth: width }}
>
<SidePanelFrame
presentation="docked"
maximized={maximized}
contentMode="full-bleed"
headerSize={taskDetailLayout ? "task-detail" : "default"}
className="flex-1 border-l-0"
header={(
<div
id={PROPERTIES_PANE_HEADER_SLOT_ID}
className="flex min-w-0 flex-1 items-center self-stretch"
/>
)}
trailingControls={(
<SidePanelWindowControls
maximized={maximized}
closeControl={taskDetailLayout ? "close" : "toggle"}
onMaximizedChange={(next) => {
if (next) handleMaximize();
else handleRestore();
}}
onToggle={() => setPanelVisible(false)}
/>
)}
footer={<div id={PROPERTIES_PANE_FOOTER_SLOT_ID} />}
>
{panelContentMode === "full-bleed" ? panelContent : (
<ScrollArea className="h-full">
<div
className={cn("p-4", maximized && "mx-auto w-full px-9")}
style={maximized ? { maxWidth: MAXIMIZED_CONTENT_MAX_WIDTH } : undefined}
>
{panelContent}
</div>
</ScrollArea>
)}
</SidePanelFrame>
</div>
</div>
</aside>
</>
);
}