mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 01:25:26 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents ask humans for decisions through approval cards, and a chat gateway plugin can forward those cards to Slack with an "Open task" button > - The button opens the bare task page; to read the document under approval, the reviewer must click four more times (open the side pane, open the Artifacts tab, open the artifact, maximize the pane) > - Approvals are the highest-frequency human touchpoint, so each removed click matters > - This pull request adds a `viewer=full` option to the existing `#document-<key>` deep link; the link now opens the target document and maximizes the side pane > - The benefit is one-click access from an external notification to a full-size reading surface for the document under approval ## Linked Issues or Issue Description **What existing behavior does this improve?** The issue page already supports `#document-<key>` deep links. They open the document in the side pane, but at the pane's default width. **Current behavior** An external link cannot request the maximized (full-size) document view. A reviewer who follows an approval notification must maximize the pane by hand each time. **Proposed behavior** `#document-<key>&viewer=full` opens the document and maximizes the side pane. Plan documents open in the Plan tab, maximized. Mobile keeps the full-screen sheet. Unknown `viewer` values are ignored, so old links and new links stay compatible in both directions. **Reason and benefit** Chat notifications about approvals can now land the reviewer directly on a full-size view of the document they must read. This removes four clicks from every approval review. **Breaking changes** None. The parameter is optional and additive. Links without it keep today's behavior. ## What Changed - `ui/src/lib/document-annotation-hash.ts`: parse and build an optional `viewer=full` parameter in document hashes. - `ui/src/lib/issue-document-deep-link.ts`: thread a `maximize` flag on properties-pane routes; the continuation-summary route is unchanged. - `ui/src/context/PanelContext.tsx`: add a one-shot panel maximize request (`requestPanelMaximize` / `clearPanelMaximizeRequest`). - `ui/src/components/PropertiesPanel.tsx`: the resizable panel host consumes a pending request once it is visible and laid out, then clears it. - `ui/src/pages/IssueDetail.tsx`: request the maximize on the desktop deep-link path only; mobile keeps the sheet. - Tests for all of the above. ## Verification - `cd ui && pnpm typecheck` — clean. - `cd ui && pnpm vitest run src/lib/document-annotation-hash.test.ts src/lib/issue-document-deep-link.test.ts src/components/PropertiesPanel.test.tsx` — 31/31 green. - New cases cover: `viewer` parse/build round trip, unknown values ignored, maximize routing for document and plan tabs, a pending request consumed on mount, and a request held while the panel is hidden. - Manual check: open an issue with `#document-<key>&viewer=full` in the URL; the pane opens on that document, maximized. Remove the parameter; the pane opens at its normal width. ## Risks - Low risk. The parameter is optional; no data, schema, or API changes. - The maximize request lives in React context as a one-shot flag. It is cleared on first consumption, so a stale request cannot re-maximize the pane on later navigations. - If a link carries `viewer=full` on a web build older than this change, the parameter is ignored and the document still opens. ## Model Used - Claude Fable 5 (`claude-fable-5`), Anthropic. Agentic coding session with extended thinking and tool use (file edits, shell, test runs). ## 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) - [ ] 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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import { useCallback, useEffect, 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";
|
|
|
|
export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
|
|
const {
|
|
panelContent,
|
|
panelContentMode,
|
|
panelVisible,
|
|
setPanelVisible,
|
|
panelMaximizeRequested,
|
|
clearPanelMaximizeRequest,
|
|
} = usePanel();
|
|
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
|
|
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
|
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
|
|
|
|
if (!panelContent) return null;
|
|
|
|
if (classicTaskInterfaceEnabled) {
|
|
return (
|
|
<aside
|
|
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-(--tp-width-opacity) duration-200 ease-in-out h-full"
|
|
style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 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"}
|
|
panelContent={panelContent}
|
|
panelContentMode={panelContentMode}
|
|
panelVisible={panelVisible}
|
|
setPanelVisible={setPanelVisible}
|
|
taskDetailLayout={streamlinedTaskDetailLayout}
|
|
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 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 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;
|
|
panelContentMode: "padded" | "prose" | "full-bleed";
|
|
panelVisible: boolean;
|
|
setPanelVisible: (visible: boolean) => void;
|
|
taskDetailLayout: boolean;
|
|
/** Pending `viewer=full` deep-link request (LOOA-2181); cleared once consumed. */
|
|
maximizeRequested: boolean;
|
|
clearMaximizeRequest: () => void;
|
|
}
|
|
|
|
function ResizablePropertiesPanel({
|
|
panelContent,
|
|
panelContentMode,
|
|
panelVisible,
|
|
setPanelVisible,
|
|
taskDetailLayout,
|
|
maximizeRequested,
|
|
clearMaximizeRequest,
|
|
}: ResizablePropertiesPanelProps) {
|
|
const defaultPaneWidth = taskDetailLayout
|
|
? TASK_DETAIL_DEFAULT_PANE_WIDTH
|
|
: DEFAULT_PANE_WIDTH;
|
|
const widthStorageKey = 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);
|
|
|
|
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(defaultPaneWidth);
|
|
clearStoredPaneWidth(widthStorageKey);
|
|
}, [defaultPaneWidth, 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],
|
|
);
|
|
|
|
const isFixed = 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}
|
|
className={cn(
|
|
"hidden md:flex bg-card flex-col",
|
|
!maximized && "border-l border-border",
|
|
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 && "transition-(--tp-width-opacity) duration-200 ease-in-out",
|
|
),
|
|
)}
|
|
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="group absolute inset-y-0 z-10 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={cn("flex-1 flex flex-col min-h-0", 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>
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|