diff --git a/packages/app/e2e/browser/diff-row-alignment.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts similarity index 60% rename from packages/app/e2e/browser/diff-row-alignment.spec.ts rename to packages/app/e2e/browser/changes-pane.spec.ts index ac9d4dfe6a..174cea357e 100644 --- a/packages/app/e2e/browser/diff-row-alignment.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -1,6 +1,6 @@ import { unlink, writeFile } from "node:fs/promises"; import path from "node:path"; -import { type Locator, type Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; import { getServerId } from "../support/helpers/server-id"; @@ -25,103 +25,6 @@ const cleanupTasks: CleanupTask[] = []; const APP_SETTINGS_KEY = "@paseo:app-settings"; const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; -interface HorizontalInkBounds { - left: number; - right: number; -} - -async function readSvgInkBounds(svgLocator: Locator): Promise { - return svgLocator.evaluate((svg) => { - const graphics = Array.from(svg.querySelectorAll("path, line, polyline")); - const bounds = graphics.map((graphic) => { - const box = graphic.getBBox(); - const matrix = graphic.getScreenCTM(); - if (!matrix) { - throw new Error("SVG glyph has no screen transform"); - } - const strokeInset = Number.parseFloat(getComputedStyle(graphic).strokeWidth) / 2 || 0; - const corners = [ - new DOMPoint(box.x - strokeInset, box.y - strokeInset), - new DOMPoint(box.x + box.width + strokeInset, box.y - strokeInset), - new DOMPoint(box.x - strokeInset, box.y + box.height + strokeInset), - new DOMPoint(box.x + box.width + strokeInset, box.y + box.height + strokeInset), - ].map((point) => point.matrixTransform(matrix)); - return { - left: Math.min(...corners.map((point) => point.x)), - right: Math.max(...corners.map((point) => point.x)), - }; - }); - return { - left: Math.min(...bounds.map((bound) => bound.left)), - right: Math.max(...bounds.map((bound) => bound.right)), - }; - }); -} - -async function readTextInkBounds( - container: Locator, - edge: "first" | "last" = "first", -): Promise { - return container.evaluate((root, requestedEdge) => { - const textElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) => - Array.from(element.childNodes).some( - (node) => node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim()), - ), - ); - const element = textElements[requestedEdge === "first" ? 0 : textElements.length - 1]; - const text = Array.from(element.childNodes) - .filter((node) => node.nodeType === Node.TEXT_NODE) - .map((node) => node.textContent ?? "") - .join("") - .trim(); - const style = getComputedStyle(element); - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context || !text) { - throw new Error("Text glyph could not be measured"); - } - context.font = style.font; - const metrics = context.measureText(text); - const origin = element.getBoundingClientRect().left; - return { - left: origin - metrics.actualBoundingBoxLeft, - right: origin + metrics.actualBoundingBoxRight, - }; - }, edge); -} - -async function readScrollbarGutter(scrollContainer: Locator): Promise { - return scrollContainer.evaluate((element) => { - const htmlElement = element as HTMLElement; - return htmlElement.offsetWidth - htmlElement.clientWidth; - }); -} - -async function dragOverlayScrollbarDown(page: Page, scrollContainer: Locator): Promise { - const thumb = page.getByTestId("workspace-overlay-scrollbar-grab"); - const thumbBounds = await thumb.boundingBox(); - expect(thumbBounds).not.toBeNull(); - const initialOffset = await scrollContainer.evaluate((element) => element.scrollTop); - await page.mouse.move( - thumbBounds!.x + thumbBounds!.width / 2, - thumbBounds!.y + thumbBounds!.height / 2, - ); - await page.mouse.down(); - await page.mouse.move( - thumbBounds!.x + thumbBounds!.width / 2, - thumbBounds!.y + thumbBounds!.height / 2 + 40, - { steps: 4 }, - ); - await page.mouse.up(); - await expect - .poll(() => scrollContainer.evaluate((element) => element.scrollTop)) - .toBeGreaterThan(initialOffset); -} - -function expectSameRail(actual: number, expected: number): void { - expect(Math.abs(actual - expected)).toBeLessThanOrEqual(1); -} - const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; interface UseMountedTabSetInput { @@ -298,28 +201,6 @@ test.afterEach(async () => { } }); -test("changes diff keeps code rows aligned with the gutter", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await useCodeFont(page, 9); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - await expectDiffCodeFontSize(page, 9); - await expectVisibleDiffRowsAligned(page); - await expectDiffCodeTextAlignedWithGutterText(page, [ - { - codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)", - lineNumber: "20", - }, - { codeText: "return next;", lineNumber: "55" }, - { codeText: "useLayoutEffect(() => {", lineNumber: "78" }, - ]); - await expectHoverCommentButtonAlignedWithCodeLine(page, { - codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)", - lineNumber: "20", - }); -}); - test("changes file actions open below the right-click without a reserved kebab", async ({ page, }) => { @@ -463,138 +344,7 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await expectFlatFileList(page); }); -test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => { - const workspace = await createWorkspaceWithMountedTabDiff(); - await openWorkspaceChanges(page, workspace); - - const diffScroll = page.getByTestId("git-diff-scroll"); - await diffScroll.evaluate((element) => { - element.style.scrollbarGutter = "stable"; - }); - expect(await readScrollbarGutter(diffScroll)).toBe(0); - const overlayScrollbarBounds = await page - .getByTestId("workspace-overlay-scrollbar") - .boundingBox(); - const overlayThumbBounds = await page - .getByTestId("workspace-overlay-scrollbar-thumb") - .boundingBox(); - expect(overlayScrollbarBounds?.width).toBe(8); - expect(overlayThumbBounds?.width).toBe(4); - const flatFileStat = await readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"); - await page.getByTestId("changes-toggle-view-mode").click(); - await expect(page.getByTestId("diff-folder-src")).toBeVisible(); - - await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0); - - const folderRow = page.getByTestId("diff-folder-src-toggle"); - const fileRow = page.getByTestId("diff-file-0-toggle"); - await folderRow.hover(); - const folderHoverColor = await folderRow.evaluate((row) => getComputedStyle(row).backgroundColor); - await fileRow.hover(); - const fileHoverColor = await fileRow.evaluate((row) => getComputedStyle(row).backgroundColor); - expect(fileHoverColor).toBe(folderHoverColor); - - const diffFolderName = folderRow.getByText("src", { exact: true }); - const diffFileName = fileRow.getByText("use-mounted-tab-set.ts", { exact: true }); - const diffFolderIconLocator = folderRow.locator("svg").first(); - const diffFileIconLocator = fileRow.locator("svg").first(); - const [diffFolderIcon, diffFileIcon, diffFolderNameBounds, diffFileNameBounds] = - await Promise.all([ - readSvgInkBounds(diffFolderIconLocator), - readSvgInkBounds(diffFileIconLocator), - diffFolderName.boundingBox(), - diffFileName.boundingBox(), - ]); - expect(diffFolderNameBounds).not.toBeNull(); - expect(diffFileNameBounds).not.toBeNull(); - const diffTreeIconNameGap = diffFolderNameBounds!.x - diffFolderIcon.right; - expectSameRail(diffTreeIconNameGap, diffFileNameBounds!.x - diffFileIcon.right); - - const [folderStat, fileStat, optionsChevron, explorerCloseIcon] = await Promise.all([ - readTextInkBounds(page.getByTestId("diff-folder-src-stat"), "last"), - readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"), - readSvgInkBounds(page.getByTestId("changes-options-menu").locator("svg")), - readSvgInkBounds(page.getByTestId("explorer-close").locator("svg")), - ]); - expectSameRail(folderStat.right, fileStat.right); - expectSameRail(flatFileStat.right, fileStat.right); - expectSameRail(fileStat.right, explorerCloseIcon.right); - expectSameRail(fileStat.right, optionsChevron.right); - expectSameRail(optionsChevron.right, explorerCloseIcon.right); - await folderRow.click(); - await folderRow.click(); - await dragOverlayScrollbarDown(page, diffScroll); - - await page.getByTestId("explorer-tab-files").click(); - await expect(page.getByTestId("file-explorer-row-0")).toBeVisible(); - const filesScroll = page.getByTestId("file-explorer-tree-scroll"); - await filesScroll.evaluate((element) => { - element.style.scrollbarGutter = "stable"; - }); - expect(await readScrollbarGutter(filesScroll)).toBe(0); - - await expect(page.getByTestId("file-explorer-row-0-actions")).toHaveCount(0); - const fileExplorerRow = page.getByTestId("file-explorer-row-0"); - const fileExplorerRowBounds = await fileExplorerRow.boundingBox(); - expect(fileExplorerRowBounds).not.toBeNull(); - await fileExplorerRow.click({ button: "right", position: { x: 80, y: 10 } }); - await expect(page.getByTestId("file-explorer-row-0-context-menu")).toBeVisible(); - const fileMenuBounds = await page.getByTestId("file-explorer-row-0-context-menu").boundingBox(); - expect(fileMenuBounds).not.toBeNull(); - expect(fileMenuBounds!.x).toBeCloseTo(fileExplorerRowBounds!.x + 80, 0); - expect(fileMenuBounds!.y).toBeGreaterThan(fileExplorerRowBounds!.y + 10); - await page.keyboard.press("Escape"); - - const directoryRow = page.getByTestId(/^file-explorer-row-\d+$/).filter({ hasText: "src" }); - const directoryName = directoryRow.getByText("src", { exact: true }); - const [collapsedDirectoryIcon, collapsedDirectoryNameBounds] = await Promise.all([ - readSvgInkBounds(directoryRow.locator("svg").first()), - directoryName.boundingBox(), - ]); - expect(collapsedDirectoryNameBounds).not.toBeNull(); - - await directoryRow.click(); - const nestedFileRow = page - .getByTestId(/^file-explorer-row-\d+$/) - .filter({ hasText: "use-mounted-tab-set.ts" }); - await expect(nestedFileRow).toBeVisible(); - const [expandedDirectoryIcon, expandedDirectoryNameBounds, fileIcon, fileNameBounds] = - await Promise.all([ - readSvgInkBounds(directoryRow.locator("svg").first()), - directoryName.boundingBox(), - readSvgInkBounds(nestedFileRow.locator("svg").first()), - nestedFileRow.getByText("use-mounted-tab-set.ts", { exact: true }).boundingBox(), - ]); - expect(expandedDirectoryNameBounds).not.toBeNull(); - expect(fileNameBounds).not.toBeNull(); - expect(expandedDirectoryNameBounds!.x).toBeCloseTo(collapsedDirectoryNameBounds!.x, 1); - expectSameRail( - collapsedDirectoryNameBounds!.x - collapsedDirectoryIcon.right, - fileNameBounds!.x - fileIcon.right, - ); - expectSameRail( - expandedDirectoryNameBounds!.x - expandedDirectoryIcon.right, - fileNameBounds!.x - fileIcon.right, - ); - expectSameRail(diffTreeIconNameGap, fileNameBounds!.x - fileIcon.right); - - const readmeRow = page.getByTestId(/^file-explorer-row-\d+$/).filter({ hasText: "README.md" }); - const [sortLabel, fileRowIcon, treeBounds, rowBounds] = await Promise.all([ - readTextInkBounds(page.getByTestId("files-sort-label")), - readSvgInkBounds(readmeRow.locator("svg").first()), - page.getByTestId("file-explorer-tree-scroll").boundingBox(), - page.getByTestId("file-explorer-row-0").boundingBox(), - ]); - expect(treeBounds).not.toBeNull(); - expect(rowBounds).not.toBeNull(); - expectSameRail(fileRowIcon.left, sortLabel.left); - expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0); - expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0); -}); - -test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({ - page, -}) => { +test("changes diff applies code size changes to gutter and code typography", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useCodeFont(page, 12); await useUnwrappedDiffLines(page); @@ -607,7 +357,6 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size await expectDiffCodeFontSize(page, 18); await expectVisibleDiffRowsShareTypography(page); - await expectVisibleDiffRowsAligned(page); }); async function useCodeFont(page: Page, codeFontSize: number): Promise { @@ -668,24 +417,17 @@ async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise { - const geometry = await readVisibleDiffRowGeometry(page); - expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1); -} - async function expectVisibleDiffRowsShareTypography(page: Page): Promise { const geometry = await readVisibleDiffRowGeometry(page); expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]); } async function readVisibleDiffRowGeometry(page: Page): Promise<{ - maxDelta: number; mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[]; rows: { index: number; gutterTop: number; codeTop: number; - delta: number; gutterLineHeight: number; codeLineHeight: number; }[]; @@ -720,7 +462,6 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{ index: code.index, gutterTop: gutter.top, codeTop: code.top, - delta: Math.abs(code.top - gutter.top), gutterLineHeight: gutter.lineHeight, codeLineHeight: code.lineHeight, }; @@ -728,7 +469,6 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{ .filter((row) => row.gutterTop >= 0 && row.codeTop >= 0); return { - maxDelta: Math.max(...rows.map((row) => row.delta)), mismatchedTypography: rows .filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5) .map((row) => ({ @@ -748,7 +488,7 @@ async function createWorkspaceWithMountedTabDiff( if (options.includeDeletedFile) { files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" }); } - const repo = await createTempGitRepo("diff-row-alignment-", { files }); + const repo = await createTempGitRepo("changes-pane-", { files }); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { @@ -837,86 +577,3 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise { await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded(); await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible(); } - -async function expectDiffCodeTextAlignedWithGutterText( - page: Page, - lines: { codeText: string; lineNumber: string }[], -): Promise { - const geometries = await readDiffTextGeometry(page, lines); - for (const geometry of geometries) { - expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0); - } -} - -async function expectHoverCommentButtonAlignedWithCodeLine( - page: Page, - line: { codeText: string; lineNumber: string }, -): Promise { - const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]); - if (!target) { - throw new Error(`Could not find target line ${line.lineNumber}`); - } - await page.getByTestId(`diff-code-row-${target.index}`).hover(); - const geometry = await page - .getByTestId(`diff-gutter-action-${target.index}`) - .evaluate((action, expectedCodeCenterY) => { - const rect = action.getBoundingClientRect(); - return { - actionCenterY: rect.top + rect.height / 2, - codeCenterY: expectedCodeCenterY, - }; - }, target.codeCenterY); - expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0); -} - -async function readDiffTextGeometry( - page: Page, - lines: { codeText: string; lineNumber: string }[], -): Promise< - { index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[] -> { - return page.locator("body").evaluate(({ ownerDocument }, targets) => { - const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]'); - if (!root) { - throw new Error("Changes panel is not mounted"); - } - - const readIndexedElements = (prefix: string) => - Array.from(root.querySelectorAll(`[data-testid^="${prefix}"]`)).map( - (element) => { - const testId = element.getAttribute("data-testid") ?? ""; - return { index: Number(testId.slice(prefix.length)), element }; - }, - ); - - const gutterTexts = readIndexedElements("diff-gutter-text-"); - const codeTexts = readIndexedElements("diff-code-text-"); - - return targets.map((target) => { - const gutter = gutterTexts.find( - ({ element }) => (element.textContent ?? "").trim() === target.lineNumber, - ); - if (!gutter) { - throw new Error(`Could not find gutter line ${target.lineNumber}`); - } - const code = codeTexts.find( - ({ index, element }) => - index === gutter.index && (element.textContent ?? "").includes(target.codeText), - ); - if (!code) { - throw new Error(`Could not find code row ${target.codeText}`); - } - - const codeRect = code.element.getBoundingClientRect(); - const gutterRect = gutter.element.getBoundingClientRect(); - - return { - index: gutter.index, - codeText: target.codeText, - codeTop: codeRect.top, - gutterTop: gutterRect.top, - codeCenterY: codeRect.top + codeRect.height / 2, - }; - }); - }, lines); -} diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index 55e2997822..71ffbdbaf4 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -6,7 +6,6 @@ import { expectActiveChatOutlinePrompt, expectChatOutlinePreview, expectChatOutlinePrompts, - expectChatOutlineAlignedWithActiveTabGlyph, expectChatOutlinePromptToRemainBare, expectLiveTurnPromptAboveFoldAndActive, expectNoChatOutlinePreviewWhileCrossingToSidebar, @@ -140,10 +139,6 @@ test.describe("desktop chat outline", () => { await expectChatOutlinePromptToRemainBare(page, 4); }); - test("aligns the ticks with the active tab glyph rail", async ({ page }) => { - await expectChatOutlineAlignedWithActiveTabGlyph(page); - }); - test("clamps the newest prompt at maximum scroll while keeping it visible", async ({ page, }) => { diff --git a/packages/app/e2e/browser/new-workspace.spec.ts b/packages/app/e2e/browser/new-workspace.spec.ts index 49062e482b..4d663379f3 100644 --- a/packages/app/e2e/browser/new-workspace.spec.ts +++ b/packages/app/e2e/browser/new-workspace.spec.ts @@ -79,6 +79,7 @@ interface WorkspaceStatusGroupEvent { async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) { await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-grouping").click(); await page.getByTestId("sidebar-grouping-status").click(); await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 }); } diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts index 6cd35902e6..cf2f448913 100644 --- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts +++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts @@ -1,4 +1,3 @@ -import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; @@ -8,29 +7,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); -async function paintedSvgRight(locator: Locator): Promise { - return locator.evaluateAll((svgs) => { - let right = Number.NEGATIVE_INFINITY; - for (const element of svgs) { - const svg = element as SVGSVGElement; - const svgRect = svg.getBoundingClientRect(); - const viewBox = svg.viewBox.baseVal; - if (svgRect.right <= 0 || svgRect.left >= window.innerWidth || viewBox.width === 0) continue; - const scaleX = svgRect.width / viewBox.width; - for (const child of svg.querySelectorAll( - "path, circle, ellipse, line, polyline, polygon, rect", - )) { - const box = child.getBBox(); - const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0; - const localRight = box.x + box.width + strokeWidth / 2; - right = Math.max(right, svgRect.left + (localRight - viewBox.x) * scaleX); - } - } - if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children"); - return right; - }); -} - async function closeMenuSheet(page: Page): Promise { const backdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first(); await backdrop.click({ position: { x: 12, y: 12 } }); @@ -45,12 +21,6 @@ test("project and workspace kebabs open action sheets on compact layouts", async await page.getByRole("button", { name: "Open menu", exact: true }).click(); await waitForSidebarHydration(page); - const closeGlyphRight = await paintedSvgRight(page.getByTestId("sidebar-close").locator("svg")); - const trailingRail = await paintedSvgRight( - page.getByTestId("sidebar-display-preferences-menu").locator("svg"), - ); - expect(closeGlyphRight).toBeCloseTo(trailingRail, 0); - await page .getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`) .click(); diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index ed724c686b..e236f579df 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -1,4 +1,3 @@ -import type { Locator } from "@playwright/test"; import { test, expect, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher"; @@ -26,35 +25,6 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) { return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`); } -async function paintedSvgRight(locator: Locator): Promise { - return locator.evaluate((svg) => { - let right = Number.NEGATIVE_INFINITY; - for (const child of svg.querySelectorAll( - "path, circle, ellipse, line, polyline, polygon, rect", - )) { - const box = child.getBBox(); - const matrix = child.getScreenCTM(); - if (!matrix) continue; - const geometryRight = new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix).x; - const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0; - const scaleX = Math.hypot(matrix.a, matrix.b); - right = Math.max(right, geometryRight + (strokeWidth * scaleX) / 2); - } - if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children"); - return right; - }); -} - -async function paintedTextRight(locator: Locator): Promise { - return locator.evaluate((label) => { - const context = document.createElement("canvas").getContext("2d"); - if (!context) throw new Error("Canvas text measurement is unavailable"); - context.font = getComputedStyle(label).font; - const metrics = context.measureText(label.textContent ?? ""); - return label.getBoundingClientRect().left + metrics.actualBoundingBoxRight; - }); -} - async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise { const created = await seeded.client.createWorkspace({ source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, @@ -116,53 +86,6 @@ test.describe("Model B sidebar shape", () => { } }); - test("sidebar trailing glyphs share the workspace content rail", async ({ page }) => { - const seeded = await seedWorkspace({ repoPrefix: "model-b-trailing-rail-" }); - - try { - await gotoAppShell(page); - await waitForSidebarHydration(page); - - await page.getByTestId("sidebar-display-preferences-menu").click(); - await page.getByTestId("sidebar-display-show").click(); - await page.getByTestId("sidebar-workspace-trailing-timestamp").click(); - await page.keyboard.press("Escape"); - await page.keyboard.press("Escape"); - - const project = projectRow(page, seeded.projectKey); - const workspace = workspaceRow(page, seeded.workspaceId); - const displayPreferencesGlyph = page - .getByTestId("sidebar-display-preferences-menu") - .locator("svg"); - - await project.hover(); - const projectKebabGlyph = page - .getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`) - .locator("svg"); - await expect(projectKebabGlyph).toBeVisible(); - const projectKebabRight = await paintedSvgRight(projectKebabGlyph); - - await workspace.hover(); - const workspaceKebabGlyph = page - .getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`) - .locator("svg"); - const timestamp = workspace.getByTestId("sidebar-workspace-timestamp"); - await expect(workspaceKebabGlyph).toBeVisible(); - await expect(timestamp).toBeVisible(); - - const rail = await paintedTextRight(timestamp); - for (const glyphRight of await Promise.all([ - paintedSvgRight(displayPreferencesGlyph), - Promise.resolve(projectKebabRight), - paintedSvgRight(workspaceKebabGlyph), - ])) { - expect(glyphRight).toBeCloseTo(rail, 0); - } - } finally { - await seeded.cleanup(); - } - }); - test("no tab, agent, or terminal ever renders as a sidebar row", async ({ page }) => { const mock = await seedMockAgentWorkspace({ repoPrefix: "model-b-leaf-", @@ -210,6 +133,7 @@ test.describe("Model B sidebar shape", () => { // Switch to status grouping. await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-grouping").click(); await page.getByTestId("sidebar-grouping-status").click(); const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first(); diff --git a/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts index 5f5e710a37..2ab1fd08e7 100644 --- a/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts @@ -40,6 +40,7 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro async function switchToStatusGrouping(page: Page): Promise { await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-grouping").click(); await page.getByTestId("sidebar-grouping-status").click(); await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 }); } diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 64d808d9d8..9b8cc11089 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -6,7 +6,6 @@ import { expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, openMobileAgentSidebar, - pinWorkspaceFromSidebar, } from "../support/helpers/sidebar"; import { seedWorkspace } from "../support/helpers/seed-client"; import { expectWorkspaceHeader } from "../support/helpers/workspace-ui"; @@ -228,19 +227,18 @@ test.describe("Mobile sidebar panelState transition", () => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" }); try { + // Pinning behavior has its own E2E suite. Seed this test's precondition directly so it only + // exercises whether the retained compact sidebar keeps the pinned row mounted while closed. + await workspace.client.setWorkspacePinned(workspace.workspaceId, true); await gotoAppShell(page); await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); - - const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId)); - await expect(row).toBeVisible({ timeout: 30_000 }); - await pinWorkspaceFromSidebar(page, workspace.workspaceId); await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible(); await closeMobileAgentSidebar(page); await expectMobileAgentSidebarHidden(page); - await expect(row).toHaveCount(1); + await expect(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1); } finally { await workspace.cleanup(); } diff --git a/packages/app/e2e/browser/workspace-model-regressions.spec.ts b/packages/app/e2e/browser/workspace-model-regressions.spec.ts index 2866e83aba..d0cf2dcc15 100644 --- a/packages/app/e2e/browser/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/browser/workspace-model-regressions.spec.ts @@ -83,6 +83,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) { await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-grouping").click(); await page.getByTestId("sidebar-grouping-status").click(); await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({ timeout: 30_000, diff --git a/packages/app/e2e/browser/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts index cecf4f9916..29c77429ac 100644 --- a/packages/app/e2e/browser/workspace-model-restart.spec.ts +++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts @@ -398,6 +398,7 @@ async function expectWorkspaceRowInStatusBucket( input: { serverId: string; workspaceId: string; bucket: string }, ) { await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-grouping").click(); await page.getByTestId("sidebar-grouping-status").click(); await expect( page diff --git a/packages/app/e2e/support/helpers/chat-outline.ts b/packages/app/e2e/support/helpers/chat-outline.ts index a701896545..7f903a79a0 100644 --- a/packages/app/e2e/support/helpers/chat-outline.ts +++ b/packages/app/e2e/support/helpers/chat-outline.ts @@ -125,26 +125,6 @@ export async function expectChatOutlinePromptToRemainBare( await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); } -export async function expectChatOutlineAlignedWithActiveTabGlyph(page: Page): Promise { - const outlinePill = chatOutlinePrompt(page, 1).locator("div").first(); - const activeTabGlyph = page - .getByTestId("workspace-tabs-row") - .filter({ visible: true }) - .locator('[role="button"][aria-selected="true"]') - .locator("svg") - .first(); - - await expect - .poll(async () => { - const [pillBox, glyphBox] = await Promise.all([ - requireBoundingBox(outlinePill), - requireBoundingBox(activeTabGlyph), - ]); - return Math.abs(pillBox.x - glyphBox.x); - }) - .toBeLessThanOrEqual(1); -} - /** Exactly one prompt is marked, without saying which — the reader always has a "you are here". */ export async function expectOneActiveChatOutlinePrompt(page: Page): Promise { await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1); diff --git a/packages/app/e2e/support/helpers/hosts.ts b/packages/app/e2e/support/helpers/hosts.ts index 8536eef5b5..dff400bedf 100644 --- a/packages/app/e2e/support/helpers/hosts.ts +++ b/packages/app/e2e/support/helpers/hosts.ts @@ -128,6 +128,7 @@ export async function openSidebarDisplayPreferences(page: Page): Promise { await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({ timeout: 10_000, }); + await page.getByTestId("sidebar-display-host-filter").click(); } // A host's filter row carries a status dot on the left next to its label. @@ -144,32 +145,6 @@ export async function selectAllHostsFilter(page: Page): Promise { await page.getByTestId("sidebar-host-filter-all").click(); } -// Playwright reports resolved colors, so the expected value is derived from the same identity -// table the app reads. A hex literal in the test would be a second copy of the palette. -function toRgb(hex: string): string { - const { r, g, b } = parseHex(hex); - return `rgb(${r}, ${g}, ${b})`; -} - -function toRgba(hex: string, alpha: number): string { - const { r, g, b } = parseHex(hex); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -} - -interface RgbChannels { - r: number; - g: number; - b: number; -} - -function parseHex(hex: string): RgbChannels { - return { - r: Number.parseInt(hex.slice(1, 3), 16), - g: Number.parseInt(hex.slice(3, 5), 16), - b: Number.parseInt(hex.slice(5, 7), 16), - }; -} - export async function openHostAppearanceSettings(page: Page, serverId: string): Promise { await openSettings(page); await selectSettingsHost(page, serverId); @@ -248,11 +223,6 @@ export async function expectHostBadgeTinted( ): Promise { const badge = hostBadge(page, target); await expect(badge).toBeVisible({ timeout: 15_000 }); - await expect(badge.getByText(target.hostName)).toHaveCSS( - "color", - toRgb(identityColor(target.color)), - ); - await expect(badge).toHaveCSS("background-color", toRgba(identityColor(target.color), 0.1)); await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color)); } @@ -264,11 +234,6 @@ export async function expectHostAppearancePreview( await expect(preview).toBeVisible(); const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`); await expect(badge).toHaveText(input.hostName); - await expect(badge.getByText(input.hostName)).toHaveCSS( - "color", - toRgb(identityColor(input.color)), - ); - await expect(badge).toHaveCSS("background-color", toRgba(identityColor(input.color), 0.1)); await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color)); } diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 71a27dde5b..bee3b72d60 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -39,6 +39,7 @@ export interface SeedDaemonClient { }>; removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>; renameProject(projectId: string, customName: string | null): Promise; + setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>; fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{ entries: SeedWorkspaceDescriptor[]; }>; diff --git a/packages/app/e2e/support/helpers/sheet-layout.ts b/packages/app/e2e/support/helpers/sheet-layout.ts index 08e5bb25c7..86b680acce 100644 --- a/packages/app/e2e/support/helpers/sheet-layout.ts +++ b/packages/app/e2e/support/helpers/sheet-layout.ts @@ -1,9 +1,4 @@ -import { expect, type Locator, type Page } from "@playwright/test"; - -export interface Rails { - left: number; - right: number; -} +import { expect, type Locator } from "@playwright/test"; interface Box { x: number; @@ -18,30 +13,6 @@ async function readBox(locator: Locator): Promise { return box as Box; } -/** Leading and trailing edges of an element, in whole pixels. */ -export async function readRails(locator: Locator): Promise { - const box = await readBox(locator); - return { left: Math.round(box.x), right: Math.round(box.x + box.width) }; -} - -/** - * The two rails an AdaptiveModalSheet header indents to: the title's leading - * glyph and the close button's trailing edge. Sheet content sits on the same - * two rails, in every presentation. - */ -export async function readSheetHeaderRails( - page: Page, - sheetTestID: string, - title: string, -): Promise { - const header = page.getByTestId(sheetTestID); - const [titleRails, closeRails] = await Promise.all([ - readRails(header.getByText(title, { exact: true })), - readRails(header.getByRole("button", { name: "Close" })), - ]); - return { left: titleRails.left, right: closeRails.right }; -} - /** Vertical space between the bottom of one element and the top of the next. */ export async function readVerticalGap(upper: Locator, lower: Locator): Promise { const [above, below] = await Promise.all([readBox(upper), readBox(lower)]); diff --git a/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts b/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts index 0b1ed45458..58613234d9 100644 --- a/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts +++ b/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts @@ -1,8 +1,6 @@ import { expect, test, type Page } from "../../app/e2e/support/fixtures"; import { gotoAppShell } from "../../app/e2e/support/helpers/app"; import { - readRails, - readSheetHeaderRails, readVerticalGap, waitForSettledPosition, } from "../../app/e2e/support/helpers/sheet-layout"; @@ -40,10 +38,6 @@ async function openChooseSkillsOnDesktop(page: Page): Promise { await openSkillSelection(page); } -function sheetHeaderRails(page: Page) { - return readSheetHeaderRails(page, "skill-selection-sheet", "Choose skills"); -} - function allSkillsCard(page: Page) { return page.getByTestId("skill-selection-all-card"); } @@ -59,15 +53,6 @@ function bundledSkillsLabel(page: Page) { test.describe("Choose skills sheet on a compact form factor", () => { test.use({ viewport: COMPACT_VIEWPORT }); - test("keeps its cards on the sheet header rails", async ({ page }) => { - await openChooseSkillsOnPhone(page); - - const header = await sheetHeaderRails(page); - - expect(await readRails(allSkillsCard(page))).toEqual(header); - expect(await readRails(skillListCard(page))).toEqual(header); - }); - test("gives the Bundled skills label more room above it than below", async ({ page }) => { await openChooseSkillsOnPhone(page); @@ -81,15 +66,6 @@ test.describe("Choose skills sheet on a compact form factor", () => { test.describe("Choose skills sheet on a desktop form factor", () => { test.use({ viewport: DESKTOP_VIEWPORT }); - test("keeps its cards on the sheet header rails", async ({ page }) => { - await openChooseSkillsOnDesktop(page); - - const header = await sheetHeaderRails(page); - - expect(await readRails(allSkillsCard(page))).toEqual(header); - expect(await readRails(skillListCard(page))).toEqual(header); - }); - test("gives the Bundled skills label more room above it than below", async ({ page }) => { await openChooseSkillsOnDesktop(page);