mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
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:
1 parent
663fe667d6
commit
e71a23c01c
9 files changed
+361
-59
No files matched your search
@@ -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.
|
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
|
## Canonical proposal toolbars
|
||||||
|
|
||||||
`Design proposals / Components / Toolbars` is the canonical toolbar review and
|
`Design proposals / Components / Toolbars` is the canonical toolbar review and
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { getTableName } from "drizzle-orm";
|
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";
|
import { HttpError } from "../errors.js";
|
||||||
|
|
||||||
vi.mock("../services/fast-responses.js", () => ({
|
vi.mock("../services/fast-responses.js", () => ({
|
||||||
@@ -376,6 +376,15 @@ function createScheduledRetry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("issue comment reopen routes", () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockIssueService.getById.mockReset();
|
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");
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -54,15 +55,16 @@ describe("PropertiesPanel", () => {
|
|||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root | null = null;
|
let root: Root | null = null;
|
||||||
let originalInnerWidth: number;
|
let originalInnerWidth: number;
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
async function renderPanel({
|
async function renderPanel({
|
||||||
panelVisible = true,
|
panelVisible = true,
|
||||||
taskDetailLayout = false,
|
taskDetailLayout = false,
|
||||||
}: { panelVisible?: boolean; taskDetailLayout?: boolean } = {}) {
|
content = <div data-testid="panel-content">content</div>,
|
||||||
mockPanelState.panelContent = <div data-testid="panel-content">content</div>;
|
}: { panelVisible?: boolean; taskDetailLayout?: boolean; content?: ReactNode } = {}) {
|
||||||
|
mockPanelState.panelContent = content;
|
||||||
mockPanelState.panelVisible = panelVisible;
|
mockPanelState.panelVisible = panelVisible;
|
||||||
root = createRoot(container);
|
root ??= createRoot(container);
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
root!.render(
|
root!.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -85,6 +87,47 @@ describe("PropertiesPanel", () => {
|
|||||||
mockClearPanelMaximizeRequest.mockClear();
|
mockClearPanelMaximizeRequest.mockClear();
|
||||||
mockPanelState.panelMaximizeRequested = false;
|
mockPanelState.panelMaximizeRequested = false;
|
||||||
mockSandbox.enabled = 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(() => {
|
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 () => {
|
it("renders the Paper task-detail rail width with a drag grip and a maximize button", async () => {
|
||||||
await renderPanel({ taskDetailLayout: true });
|
await renderPanel({ taskDetailLayout: true });
|
||||||
const aside = container.querySelector("aside");
|
const aside = container.querySelector("aside");
|
||||||
@@ -140,7 +199,7 @@ describe("PropertiesPanel", () => {
|
|||||||
expect(aside!.style.width).toBe("434px");
|
expect(aside!.style.width).toBe("434px");
|
||||||
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
|
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
|
||||||
expect(container.querySelector('[aria-label="Maximize side 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.width).toBe("434px");
|
||||||
expect(inner!.style.minWidth).toBe("434px");
|
expect(inner!.style.minWidth).toBe("434px");
|
||||||
expect(aside!.querySelector("header")?.className).toContain(
|
expect(aside!.querySelector("header")?.className).toContain(
|
||||||
@@ -177,11 +236,11 @@ describe("PropertiesPanel", () => {
|
|||||||
'[aria-label="Maximize side panel"]',
|
'[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();
|
maximize.click();
|
||||||
await flushReact();
|
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");
|
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { X } from "lucide-react";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
|
import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
|
||||||
@@ -22,14 +22,17 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
|
|||||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||||
const designSandbox = useDesignSandbox();
|
const designSandbox = useDesignSandbox();
|
||||||
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
|
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
|
||||||
|
// Keep the zero-width host mounted while route data loads. Inserting an
|
||||||
if (!panelContent) return null;
|
// already-open pane skips its width transition and snaps the thread sideways.
|
||||||
|
const visible = panelVisible && panelContent != null;
|
||||||
|
|
||||||
if (classicTaskInterfaceEnabled) {
|
if (classicTaskInterfaceEnabled) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<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"
|
inert={!visible}
|
||||||
style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 1 : 0 }}
|
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="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">
|
<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"}`}
|
key={`${streamlinedTaskDetailLayout ? "task-detail" : "default"}-${designSandbox ? "proposal" : "production"}`}
|
||||||
panelContent={panelContent}
|
panelContent={panelContent}
|
||||||
panelContentMode={panelContentMode}
|
panelContentMode={panelContentMode}
|
||||||
panelVisible={panelVisible}
|
panelVisible={visible}
|
||||||
setPanelVisible={setPanelVisible}
|
setPanelVisible={setPanelVisible}
|
||||||
taskDetailLayout={streamlinedTaskDetailLayout}
|
taskDetailLayout={streamlinedTaskDetailLayout}
|
||||||
designSandbox={designSandbox}
|
designSandbox={designSandbox}
|
||||||
@@ -158,7 +161,7 @@ interface FixedPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ResizablePropertiesPanelProps {
|
interface ResizablePropertiesPanelProps {
|
||||||
panelContent: ReactNode;
|
panelContent: ReactNode | null;
|
||||||
panelContentMode: "padded" | "prose" | "full-bleed";
|
panelContentMode: "padded" | "prose" | "full-bleed";
|
||||||
panelVisible: boolean;
|
panelVisible: boolean;
|
||||||
setPanelVisible: (visible: boolean) => void;
|
setPanelVisible: (visible: boolean) => void;
|
||||||
@@ -195,6 +198,12 @@ function ResizablePropertiesPanel({
|
|||||||
const [maximized, setMaximized] = useState(false);
|
const [maximized, setMaximized] = useState(false);
|
||||||
const [fixedPane, setFixedPane] = useState<FixedPane | null>(null);
|
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 asideRef = useRef<HTMLElement | null>(null);
|
||||||
const widthRef = useRef(width);
|
const widthRef = useRef(width);
|
||||||
widthRef.current = width;
|
widthRef.current = width;
|
||||||
@@ -349,7 +358,9 @@ function ResizablePropertiesPanel({
|
|||||||
[maximized, finishRestore],
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -364,9 +375,10 @@ function ResizablePropertiesPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
<aside
|
<aside
|
||||||
ref={asideRef}
|
ref={asideRef}
|
||||||
|
inert={!panelVisible}
|
||||||
|
aria-hidden={!panelVisible}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden md:flex bg-card flex-col",
|
"hidden md:flex bg-card flex-col",
|
||||||
!maximized && "border-l border-border",
|
|
||||||
isFixed
|
isFixed
|
||||||
? "tc-pane-glide fixed z-40 overflow-hidden"
|
? "tc-pane-glide fixed z-40 overflow-hidden"
|
||||||
: cn(
|
: cn(
|
||||||
@@ -374,7 +386,7 @@ function ResizablePropertiesPanel({
|
|||||||
panelVisible ? "overflow-visible" : "overflow-hidden",
|
panelVisible ? "overflow-visible" : "overflow-hidden",
|
||||||
// The width/opacity transition would fight pointer-driven
|
// The width/opacity transition would fight pointer-driven
|
||||||
// resizing, so it is suspended while dragging.
|
// 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={
|
style={
|
||||||
@@ -395,7 +407,7 @@ function ResizablePropertiesPanel({
|
|||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-label="Resize panel"
|
aria-label="Resize panel"
|
||||||
data-dragging={dragging ? "" : undefined}
|
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 }}
|
style={{ left: -4, width: 8 }}
|
||||||
onPointerDown={handleGripPointerDown}
|
onPointerDown={handleGripPointerDown}
|
||||||
onPointerMove={handleGripPointerMove}
|
onPointerMove={handleGripPointerMove}
|
||||||
@@ -412,46 +424,48 @@ function ResizablePropertiesPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
className={cn("flex-1 flex flex-col min-h-0", isFixed && "w-full")}
|
<div
|
||||||
style={isFixed ? undefined : { width, minWidth: width }}
|
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 : (
|
<SidePanelFrame
|
||||||
<ScrollArea className="h-full">
|
presentation="docked"
|
||||||
|
maximized={maximized}
|
||||||
|
contentMode="full-bleed"
|
||||||
|
headerSize={taskDetailLayout ? "task-detail" : "default"}
|
||||||
|
className="flex-1 border-l-0"
|
||||||
|
header={(
|
||||||
<div
|
<div
|
||||||
className={cn("p-4", maximized && "mx-auto w-full px-9")}
|
id={PROPERTIES_PANE_HEADER_SLOT_ID}
|
||||||
style={maximized ? { maxWidth: MAXIMIZED_CONTENT_MAX_WIDTH } : undefined}
|
className="flex min-w-0 flex-1 items-center self-stretch"
|
||||||
>
|
/>
|
||||||
{panelContent}
|
)}
|
||||||
</div>
|
trailingControls={(
|
||||||
</ScrollArea>
|
<SidePanelWindowControls
|
||||||
)}
|
maximized={maximized}
|
||||||
</SidePanelFrame>
|
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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
|||||||
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
|
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
|
||||||
{ name: "--motion-ease-in", 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-width-reset", group: "Easing", kind: "easing" },
|
||||||
|
{ name: "--motion-ease-pane-reveal", group: "Easing", kind: "easing" },
|
||||||
|
|
||||||
// Duration primitives
|
// Duration primitives
|
||||||
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
|
{ 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-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-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-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-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 },
|
{ name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||||
];
|
];
|
||||||
|
|||||||
+7
-1
@@ -461,6 +461,8 @@
|
|||||||
--motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1);
|
--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-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0);
|
||||||
--motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */
|
--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-pane-width-reset: 200ms;
|
||||||
--motion-ease-pane-width-reset: ease-in-out;
|
--motion-ease-pane-width-reset: ease-in-out;
|
||||||
--motion-side-panel-tab: var(--motion-duration-fast);
|
--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
|
/* Properties-pane maximize/restore glide (left animates between docked and
|
||||||
sidebar-flush while the pane is position:fixed). */
|
sidebar-flush while the pane is position:fixed). */
|
||||||
.tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); }
|
.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[data-width-resetting="true"] > .sidebar-shell-panel { transition: width var(--motion-pane-width-reset) var(--motion-ease-pane-width-reset); }
|
.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); }
|
.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-turn-metrics,
|
||||||
.tc-line-scroll-inner,
|
.tc-line-scroll-inner,
|
||||||
.tc-pane-glide,
|
.tc-pane-glide,
|
||||||
|
.properties-panel-docked,
|
||||||
.side-panel-tab-motion,
|
.side-panel-tab-motion,
|
||||||
.side-panel-tab-close-motion { transition: none; }
|
.side-panel-tab-close-motion { transition: none; }
|
||||||
/* The pill keyframes carry the X-centering; with animation:none restore it. */
|
/* 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-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-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-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-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-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]). */
|
--tp-transform-opacity: transform,opacity; /* Extracted from ui/src/components/ToastViewport.tsx (transition-[transform,opacity]). */
|
||||||
|
|||||||
@@ -981,7 +981,7 @@ function useTaskDetailInterfaceMode(conversationMode = false) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueDetailLoadingState({
|
export function IssueDetailLoadingState({
|
||||||
headerSeed,
|
headerSeed,
|
||||||
}: {
|
}: {
|
||||||
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
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 } };
|
||||||
Reference in new issue
Block a user