From e71a23c01c8af0e0b4cc5ac983d86886ba324239 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:10:45 -0500 Subject: [PATCH] fix(ui): animate late task sidebar loading (#17880) https://entire.com/gh/paperclipai/-/trails/61/changes/paperclip/61 ## 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 --- DESIGN.md | 2 + .../issue-comment-reopen-routes.test.ts | 11 +- .../task-sidebar-loading.spec.ts | 111 +++++++++++++++++ ui/src/components/PropertiesPanel.test.tsx | 73 ++++++++++-- ui/src/components/PropertiesPanel.tsx | 112 ++++++++++-------- ui/src/components/task-chat/motion-tokens.ts | 2 + ui/src/index.css | 8 +- ui/src/pages/IssueDetail.tsx | 2 +- .../tasks/task-sidebar-loading.stories.tsx | 99 ++++++++++++++++ 9 files changed, 361 insertions(+), 59 deletions(-) create mode 100644 tests/storybook-visual/task-sidebar-loading.spec.ts create mode 100644 ui/storybook/stories/tasks/task-sidebar-loading.stories.tsx diff --git a/DESIGN.md b/DESIGN.md index bda54f4da2b..af538661b68 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index dbbef012e9f..ba1df3c8b86 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -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(); diff --git a/tests/storybook-visual/task-sidebar-loading.spec.ts b/tests/storybook-visual/task-sidebar-loading.spec.ts new file mode 100644 index 00000000000..77bced22d48 --- /dev/null +++ b/tests/storybook-visual/task-sidebar-loading.spec.ts @@ -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"); +}); diff --git a/ui/src/components/PropertiesPanel.test.tsx b/ui/src/components/PropertiesPanel.test.tsx index c5d1ebc0794..f4562d26d49 100644 --- a/ui/src/components/PropertiesPanel.test.tsx +++ b/ui/src/components/PropertiesPanel.test.tsx @@ -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 =
content
; + content =
content
, + }: { 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( @@ -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(":scope > div:not([role])"); + const inner = aside!.querySelector(":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"); }); diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx index babd7bed3c0..e7dcf746ccd 100644 --- a/ui/src/components/PropertiesPanel.tsx +++ b/ui/src/components/PropertiesPanel.tsx @@ -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 ( diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index 08086aca402..068cf982b98 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -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 }, ]; diff --git a/ui/src/index.css b/ui/src/index.css index 27e9dc1edc7..195d5cee7b2 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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]). */ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6e79c863101..8d37738c3ed 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -981,7 +981,7 @@ function useTaskDetailInterfaceMode(conversationMode = false) { }; } -function IssueDetailLoadingState({ +export function IssueDetailLoadingState({ headerSeed, }: { headerSeed: ReturnType; diff --git a/ui/storybook/stories/tasks/task-sidebar-loading.stories.tsx b/ui/storybook/stories/tasks/task-sidebar-loading.stories.tsx new file mode 100644 index 00000000000..b06d0d96896 --- /dev/null +++ b/ui/storybook/stories/tasks/task-sidebar-loading.stories.tsx @@ -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( + , + { 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 ( +
+
+ + + Delayed fixture data · uses the production skeleton and sidebar host +
+
+
+ {loaded ? ( +
+ {issue.identifier} +

{issue.title}

+ {issue.description ?? "The task is ready. The sidebar and thread share the same layout transition."} +
+ ) : } +
+ +
+
+ ); +} + +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; + +export default meta; +type Story = StoryObj; + +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 } };