mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
<!-- 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>
474 lines
18 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|