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 =