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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-27 10:10:45 -05:00
1 parent 663fe667d6
commit e71a23c01c
9 files changed
+361 -59

No files matched your search

+2
View File
@@ -867,6 +867,8 @@ tokenize motion. Principles — reasoning only; values live in `ui/src/index.css
Agent Chat and regular task chats keep pending questions and requests as compact, reopenable entries at their original position in history. Dismissing any composer request—including completion reviews, confirmations, choices, suggested tasks, item verdicts, and runtime input—removes its form and pending reminder. Saved interactions record human dismissal on the server; raw runtime requests retain a browser-local preference. Dismissal is not an answer, approval, or rejection. Sending a newer user message also clears an ordinary question from the composer without resolving it. Questions never contribute to composer pending counts or navigation. Opening the history entry restores the original form and its draft; submitting later uses the same durable question response path. Actual permission reviews retain their permission checks.
Desktop properties panels keep a zero-width, noninteractive host while their content is unknown. Late content expands the panel and moves the thread in the same width transition. Removing content on navigation clears the pane immediately, including maximized geometry. The motion uses `--motion-pane-reveal` and `--motion-ease-pane-reveal`; reduced motion disables it. A saved hidden preference stays hidden, and pointer resizing stays immediate.
## Canonical proposal toolbars
`Design proposals / Components / Toolbars` is the canonical toolbar review and
@@ -1,7 +1,7 @@
import express from "express";
import request from "supertest";
import { getTableName } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
vi.mock("../services/fast-responses.js", () => ({
@@ -376,6 +376,15 @@ function createScheduledRetry() {
}
describe("issue comment reopen routes", () => {
beforeAll(async () => {
// Load the route graph within the setup budget. A first-test import can
// time out and continue into the next test after its mocks are reset.
await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
]);
});
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockReset();
@@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 1440, height: 960 }, reducedMotion: "no-preference" });
async function sampleLayout(page: import("@playwright/test").Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: Array<{ time: number; pane: number; main: number; skeleton: boolean; overflow: boolean }> = [];
const start = performance.now();
while (performance.now() - start < duration) {
samples.push({
time: performance.now() - start,
pane: document.querySelector("aside")!.getBoundingClientRect().width,
main: document.querySelector("main")!.getBoundingClientRect().width,
skeleton: Boolean(document.querySelector('[data-testid="issue-chat-skeleton"]')),
overflow: document.documentElement.scrollWidth > innerWidth,
});
await new Promise(requestAnimationFrame);
}
return samples;
}, duration);
}
for (const scenario of ["sidebar-during-skeleton", "sidebar-after-content"]) {
test(`${scenario} moves the thread with the arriving sidebar`, async ({ page }) => {
await page.goto(`/iframe.html?id=tasks-sidebar-loading--${scenario}&viewMode=story`);
const pane = page.locator("aside");
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434);
await page.getByRole("button", { name: "Replay cold load" }).click();
await expect(pane).toHaveAttribute("inert", "");
const samples = await sampleLayout(page, 3000);
// Route cleanup removes the old pane immediately, before the delayed arrival.
expect(samples.filter((sample) => sample.time < 700).every((sample) => sample.pane === 0)).toBe(true);
const reveal = samples.filter((sample) => sample.time > 700 && sample.pane > 2 && sample.pane < 432);
expect(reveal.length).toBeGreaterThan(0);
expect(reveal.some((sample) => sample.skeleton)).toBe(scenario === "sidebar-during-skeleton");
for (const sample of samples) {
expect(Math.abs(sample.main + sample.pane - 1440)).toBeLessThan(2);
expect(sample.overflow).toBe(false);
}
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434);
await expect(pane).not.toHaveAttribute("inert");
});
}
for (const scenario of ["without-sidebar", "hidden-sidebar"]) {
test(`${scenario} leaves the full thread width available`, async ({ page }) => {
await page.goto(`/iframe.html?id=tasks-sidebar-loading--${scenario}&viewMode=story`);
await expect(page.getByRole("button", { name: "Replay cold load" })).toBeVisible();
const samples = await sampleLayout(page, 2700);
expect(samples.every((sample) => sample.pane === 0 && sample.main === 1440)).toBe(true);
await expect(page.locator("aside")).toHaveAttribute("inert", "");
if (scenario === "hidden-sidebar") {
await page.getByRole("button", { name: "Show sidebar" }).click();
await expect.poll(() => page.locator("aside").evaluate((el) => el.getBoundingClientRect().width)).toBe(434);
}
});
}
test("reduced motion reveals the sidebar without a transition", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story");
await expect(page.getByRole("button", { name: "Replay cold load" })).toBeVisible();
const samples = await sampleLayout(page, 2700);
expect(samples.every((sample) => sample.pane === 0 || sample.pane === 434)).toBe(true);
await expect.poll(() => page.locator("aside").evaluate((el) => getComputedStyle(el).transitionDuration)).toBe("0s");
});
test("resizing stays immediate and maximize restores the docked width", async ({ page }) => {
await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story");
const pane = page.locator("aside");
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434);
const grip = page.getByRole("separator", { name: "Resize panel" });
const bounds = (await grip.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 - 90, bounds.y + bounds.height / 2);
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(524);
expect(await pane.evaluate((el) => getComputedStyle(el).transitionDuration)).toBe("0s");
await page.mouse.up();
await page.getByRole("button", { name: "Maximize side panel" }).click();
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(1440);
await page.getByRole("button", { name: "Restore side panel" }).click();
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(524);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1440);
});
test("a viewport narrowed during loading clamps the sidebar before reveal", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("taskChatRedesign.taskDetailPropertiesPaneWidth", "700"));
await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story&args=sidebarDelay:3500;contentDelay:4500");
await page.getByRole("button", { name: "Replay cold load" }).click();
const pane = page.locator("aside");
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(0);
await page.setViewportSize({ width: 1000, height: 960 });
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width), { timeout: 8000 }).toBe(344);
expect(await page.evaluate(() => localStorage.getItem("taskChatRedesign.taskDetailPropertiesPaneWidth"))).toBe("700");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000);
});
test("removing maximized content releases the empty pane immediately", async ({ page }) => {
await page.goto("/iframe.html?id=tasks-sidebar-loading--sidebar-during-skeleton&viewMode=story");
const pane = page.locator("aside");
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(434);
await page.getByRole("button", { name: "Maximize side panel" }).click();
await expect.poll(() => pane.evaluate((el) => el.getBoundingClientRect().width)).toBe(1440);
await page.getByRole("button", { name: "Replay cold load" }).click();
await expect(pane).toHaveAttribute("inert", "");
const samples = await sampleLayout(page, 500);
expect(samples.filter((sample) => sample.pane !== 0 || sample.main !== 1440 || sample.overflow)).toEqual([]);
await expect(pane).toHaveAttribute("inert", "");
expect(await pane.evaluate((el) => getComputedStyle(el).transitionDuration)).toBe("0s");
});
+66 -7
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -54,15 +55,16 @@ describe("PropertiesPanel", () => {
let container: HTMLDivElement;
let root: Root | null = null;
let originalInnerWidth: number;
let queryClient: QueryClient;
async function renderPanel({
panelVisible = true,
taskDetailLayout = false,
}: { panelVisible?: boolean; taskDetailLayout?: boolean } = {}) {
mockPanelState.panelContent = <div data-testid="panel-content">content</div>;
content = <div data-testid="panel-content">content</div>,
}: { panelVisible?: boolean; taskDetailLayout?: boolean; content?: ReactNode } = {}) {
mockPanelState.panelContent = content;
mockPanelState.panelVisible = panelVisible;
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root ??= createRoot(container);
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
@@ -85,6 +87,47 @@ describe("PropertiesPanel", () => {
mockClearPanelMaximizeRequest.mockClear();
mockPanelState.panelMaximizeRequested = false;
mockSandbox.enabled = false;
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
describe.each([false, true])("late content with classic interface %s", (classic) => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableStreamlinedUi: true,
enableClassicTaskInterface: classic,
});
});
it("keeps the same zero-width host when content arrives and leaves", async () => {
await renderPanel({ content: null });
const aside = container.querySelector("aside")!;
expect(aside).not.toBeNull();
expect(aside.style.width).toBe("0px");
expect(aside.hasAttribute("inert")).toBe(true);
expect(aside.getAttribute("aria-hidden")).toBe("true");
expect(aside.classList.contains("properties-panel-docked")).toBe(false);
await renderPanel();
expect(container.querySelector("aside")).toBe(aside);
expect(aside.style.width).toBe(classic ? "320px" : "322px");
expect(aside.hasAttribute("inert")).toBe(false);
expect(aside.classList.contains("properties-panel-docked")).toBe(true);
await renderPanel({ content: null });
expect(container.querySelector("aside")).toBe(aside);
expect(aside.style.width).toBe("0px");
expect(aside.classList.contains("properties-panel-docked")).toBe(false);
expect(mockSetPanelVisible).not.toHaveBeenCalled();
});
it("honors a hidden-panel preference when late content arrives", async () => {
await renderPanel({ panelVisible: false, content: null });
await renderPanel({ panelVisible: false });
const aside = container.querySelector("aside")!;
expect(aside.style.width).toBe("0px");
expect(aside.hasAttribute("inert")).toBe(true);
expect(mockSetPanelVisible).not.toHaveBeenCalled();
});
});
afterEach(() => {
@@ -133,6 +176,22 @@ describe("PropertiesPanel", () => {
});
});
it.each([false, true])("clamps late content to a narrowed viewport (task detail: %s)", async (taskDetailLayout) => {
const storageKey = taskDetailLayout
? "taskChatRedesign.taskDetailPropertiesPaneWidth"
: "taskChatRedesign.propertiesPaneWidth";
window.localStorage.setItem(storageKey, "700");
await renderPanel({ taskDetailLayout, content: null });
const aside = container.querySelector("aside")!;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
await renderPanel({ taskDetailLayout });
expect(container.querySelector("aside")).toBe(aside);
expect(aside.style.width).toBe("344px");
expect(window.localStorage.getItem(storageKey)).toBe("700");
});
it("renders the Paper task-detail rail width with a drag grip and a maximize button", async () => {
await renderPanel({ taskDetailLayout: true });
const aside = container.querySelector("aside");
@@ -140,7 +199,7 @@ describe("PropertiesPanel", () => {
expect(aside!.style.width).toBe("434px");
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Maximize side panel"]')).not.toBeNull();
const inner = aside!.querySelector<HTMLDivElement>(":scope > div:not([role])");
const inner = aside!.querySelector<HTMLDivElement>(":scope > div:not([role]) > div");
expect(inner!.style.width).toBe("434px");
expect(inner!.style.minWidth).toBe("434px");
expect(aside!.querySelector("header")?.className).toContain(
@@ -177,11 +236,11 @@ describe("PropertiesPanel", () => {
'[aria-label="Maximize side panel"]',
)!;
expect(aside.className).toContain("border-l");
expect(aside.querySelector(":scope > div:not([role]) > div")!.className).toContain("border-l");
maximize.click();
await flushReact();
expect(aside.className).not.toContain("border-l");
expect(aside.querySelector(":scope > div:not([role]) > div")!.className).not.toContain("border-l");
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
});
+63 -49
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
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";
@@ -22,14 +22,17 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const designSandbox = useDesignSandbox();
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
if (!panelContent) return null;
// 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
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 }}
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">
@@ -51,7 +54,7 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
key={`${streamlinedTaskDetailLayout ? "task-detail" : "default"}-${designSandbox ? "proposal" : "production"}`}
panelContent={panelContent}
panelContentMode={panelContentMode}
panelVisible={panelVisible}
panelVisible={visible}
setPanelVisible={setPanelVisible}
taskDetailLayout={streamlinedTaskDetailLayout}
designSandbox={designSandbox}
@@ -158,7 +161,7 @@ interface FixedPane {
}
interface ResizablePropertiesPanelProps {
panelContent: ReactNode;
panelContent: ReactNode | null;
panelContentMode: "padded" | "prose" | "full-bleed";
panelVisible: boolean;
setPanelVisible: (visible: boolean) => void;
@@ -195,6 +198,12 @@ function ResizablePropertiesPanel({
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;
@@ -349,7 +358,9 @@ function ResizablePropertiesPanel({
[maximized, finishRestore],
);
const isFixed = fixedPane !== null;
// 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 (
<>
@@ -364,9 +375,10 @@ function ResizablePropertiesPanel({
) : null}
<aside
ref={asideRef}
inert={!panelVisible}
aria-hidden={!panelVisible}
className={cn(
"hidden md:flex bg-card flex-col",
!maximized && "border-l border-border",
isFixed
? "tc-pane-glide fixed z-40 overflow-hidden"
: cn(
@@ -374,7 +386,7 @@ function ResizablePropertiesPanel({
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-(--motion-pane-width-reset) ease-(--motion-ease-pane-width-reset)",
!dragging && panelContent != null && "properties-panel-docked",
),
)}
style={
@@ -395,7 +407,7 @@ function ResizablePropertiesPanel({
aria-orientation="vertical"
aria-label="Resize panel"
data-dragging={dragging ? "" : undefined}
className="pane-resize-handle group absolute inset-y-0 z-10 cursor-col-resize touch-none"
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}
@@ -412,46 +424,48 @@ function ResizablePropertiesPanel({
/>
</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} />}
<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 }}
>
{panelContentMode === "full-bleed" ? panelContent : (
<ScrollArea className="h-full">
<SidePanelFrame
presentation="docked"
maximized={maximized}
contentMode="full-bleed"
headerSize={taskDetailLayout ? "task-detail" : "default"}
className="flex-1 border-l-0"
header={(
<div
className={cn("p-4", maximized && "mx-auto w-full px-9")}
style={maximized ? { maxWidth: MAXIMIZED_CONTENT_MAX_WIDTH } : undefined}
>
{panelContent}
</div>
</ScrollArea>
)}
</SidePanelFrame>
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>
</>
@@ -31,6 +31,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
{ name: "--motion-ease-in", group: "Easing", kind: "easing" },
{ name: "--motion-ease-pane-width-reset", group: "Easing", kind: "easing" },
{ name: "--motion-ease-pane-reveal", group: "Easing", kind: "easing" },
// Duration primitives
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
@@ -76,6 +77,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-mobile-nav-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scrollbar-idle-delay", group: "States", kind: "time", min: 0, max: 2000, step: 10 },
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-pane-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-pane-width-reset", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
];
+7 -1
View File
@@ -461,6 +461,8 @@
--motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1);
--motion-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0);
--motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */
--motion-pane-reveal: var(--motion-duration-slow);
--motion-ease-pane-reveal: var(--motion-ease-out-expo);
--motion-pane-width-reset: 200ms;
--motion-ease-pane-width-reset: ease-in-out;
--motion-side-panel-tab: var(--motion-duration-fast);
@@ -1384,6 +1386,10 @@ html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root
/* Properties-pane maximize/restore glide (left animates between docked and
sidebar-flush while the pane is position:fixed). */
.tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); }
.properties-panel-docked {
transition: width var(--motion-pane-reveal) var(--motion-ease-pane-reveal),
opacity var(--motion-pane-reveal) var(--motion-ease-pane-reveal);
}
.sidebar-shell[data-width-resetting="true"],
.sidebar-shell[data-width-resetting="true"] > .sidebar-shell-panel { transition: width var(--motion-pane-width-reset) var(--motion-ease-pane-width-reset); }
.design-page-aside[data-width-resetting="true"] { transition: flex-basis var(--motion-pane-width-reset) var(--motion-ease-pane-width-reset); }
@@ -1457,6 +1463,7 @@ html:has(.paperclip-app-shell[data-mobile="true"] [data-task-chat-shell]) #root
.tc-turn-metrics,
.tc-line-scroll-inner,
.tc-pane-glide,
.properties-panel-docked,
.side-panel-tab-motion,
.side-panel-tab-close-motion { transition: none; }
/* The pill keyframes carry the X-centering; with animation:none restore it. */
@@ -3177,7 +3184,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--tp-max-height: max-height; /* Extracted from ui/src/components/FoldCurtain.tsx (transition-[max-height]). */
--tp-border-color-background-color-box-shadow: border-color,background-color,box-shadow; /* Extracted from ui/src/components/IssueChatThread.tsx (transition-[border-color,background-color,box-shadow]). */
--tp-width-min-width: width,min-width; /* Extracted from ui/src/components/KanbanBoard.tsx (transition-[width,min-width]). */
--tp-width-opacity: width,opacity; /* Extracted from ui/src/components/OnboardingWizard.tsx (transition-[width,opacity]). */
--tp-background-color-right: background-color,right; /* Extracted from ui/src/components/ScrollToBottom.tsx (transition-[background-color,right]). */
--tp-background-color-border-color-color: background-color,border-color,color; /* Extracted from ui/src/components/SystemNotice.tsx (transition-[background-color,border-color,color]). */
--tp-transform-opacity: transform,opacity; /* Extracted from ui/src/components/ToastViewport.tsx (transition-[transform,opacity]). */
+1 -1
View File
@@ -981,7 +981,7 @@ function useTaskDetailInterfaceMode(conversationMode = false) {
};
}
function IssueDetailLoadingState({
export function IssueDetailLoadingState({
headerSeed,
}: {
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PropertiesPanel } from "@/components/PropertiesPanel";
import { TaskSidePanel } from "@/components/task-side-panel";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { usePanel } from "@/context/PanelContext";
import { IssueDetailLoadingState } from "@/pages/IssueDetail";
import { storybookIssues } from "../../fixtures/paperclipData";
const issue = storybookIssues[0]!;
const noUpdate = () => {};
function LoadingTransition({ sidebar, initiallyHidden, sidebarDelay, contentDelay }: {
sidebar: boolean;
initiallyHidden: boolean;
sidebarDelay: number;
contentDelay: number;
}) {
const { openPanel, closePanel, setPanelVisible } = usePanel();
const [replay, setReplay] = useState(0);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
closePanel();
setLoaded(false);
setPanelVisible(!initiallyHidden);
const panelTimer = sidebar ? window.setTimeout(() => {
openPanel(
<TaskSidePanel
issue={issue}
accountScope="storybook-loading"
fileTabsEnabled={false}
streamlinedTabs
showSubtasksTab
onUpdate={noUpdate}
/>,
{ contentMode: "full-bleed" },
);
}, sidebarDelay) : undefined;
const contentTimer = window.setTimeout(() => setLoaded(true), contentDelay);
return () => {
window.clearTimeout(panelTimer);
window.clearTimeout(contentTimer);
closePanel();
};
}, [replay, sidebar, initiallyHidden, sidebarDelay, contentDelay, openPanel, closePanel, setPanelVisible]);
return (
<div className="flex h-dvh flex-col bg-background text-foreground">
<div className="flex items-center gap-3 border-b border-border px-6 py-3">
<Button variant="outline" size="sm" onClick={() => setReplay((current) => current + 1)}>Replay cold load</Button>
<Button variant="ghost" size="sm" onClick={() => setPanelVisible(true)}>Show sidebar</Button>
<span className="text-xs text-muted-foreground">Delayed fixture data · uses the production skeleton and sidebar host</span>
</div>
<div className="streamlined-task-detail-surface flex min-h-0 flex-1">
<main className="min-w-0 flex-1 overflow-auto p-6">
{loaded ? (
<div className="mx-auto max-w-(--tc-shell-max-w) space-y-6">
<span className="text-sm text-muted-foreground">{issue.identifier}</span>
<h2 className="text-xl font-bold">{issue.title}</h2>
<MarkdownBody>{issue.description ?? "The task is ready. The sidebar and thread share the same layout transition."}</MarkdownBody>
</div>
) : <IssueDetailLoadingState headerSeed={null} />}
</main>
<PropertiesPanel taskDetailLayout />
</div>
</div>
);
}
const meta = {
id: "tasks-sidebar-loading",
title: "Tasks and conversations/Task creation and lifecycle/Sidebar loading",
component: LoadingTransition,
parameters: {
layout: "fullscreen",
paperclip: {
capabilities: ["tasks/properties", "tasks/discussion"],
status: "current",
implementation: "production",
boundary: "fixtures",
notes: "Production PropertiesPanel, TaskSidePanel, and IssueDetailLoadingState in a local flex shell. Replay late sidebar discovery, content-first loading, absent sidebars, and a saved hidden preference. Timers simulate data arrival; resolved thread content is a fixture, not the full task page. Browser reduced-motion preferences apply.",
},
},
args: { sidebar: true, initiallyHidden: false, sidebarDelay: 1200, contentDelay: 2400 },
argTypes: {
sidebarDelay: { control: { type: "range", min: 0, max: 5000, step: 100 } },
contentDelay: { control: { type: "range", min: 0, max: 5000, step: 100 } },
},
} satisfies Meta<typeof LoadingTransition>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SidebarDuringSkeleton: Story = {};
export const SidebarAfterContent: Story = { args: { sidebarDelay: 2400, contentDelay: 800 } };
export const WithoutSidebar: Story = { args: { sidebar: false } };
export const HiddenSidebar: Story = { args: { initiallyHidden: true } };