Merge remote-tracking branch 'origin/main'

This commit is contained in:
Mohamed Boudra
2026-08-04 13:13:22 +02:00
25 changed files with 201 additions and 619 deletions
+16
View File
@@ -96,6 +96,21 @@ Any gap between A and B (margins between siblings inside the same parent) is par
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
### Failure mode 4 — A hover-revealed trigger unmounts its own open menu
A kebab that only exists while its row is hovered opens a menu, and that menu takes the pointer
off the row: a sheet slides up over it, a popover covers it. The row un-hovers, the trailing
overlay unmounts, and the trigger goes with it — taking the menu's open state, which lived
inside the trigger's subtree. The surface itself is already presented in a portal, so nothing
unmounts it and nothing is left that can close it. On a bottom sheet that means a full-screen
backdrop swallowing every click until reload.
Lift the menu's open state to whatever decides to render the trigger, and keep the trigger
rendered while the menu is up. `useOpenKebabMenuVisibility`
(`packages/app/src/components/sidebar/use-open-kebab-menu-visibility.ts`) is that shape for the
sidebar rows: it owns `open`, hands the menu its controlled props, and ORs `open` into the
row's own `showKebab`.
## Native fallback
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
@@ -134,5 +149,6 @@ Before opening a PR that touches hover:
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
@@ -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<HorizontalInkBounds> {
return svgLocator.evaluate((svg) => {
const graphics = Array.from(svg.querySelectorAll<SVGGraphicsElement>("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<HorizontalInkBounds> {
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<number> {
return scrollContainer.evaluate((element) => {
const htmlElement = element as HTMLElement;
return htmlElement.offsetWidth - htmlElement.clientWidth;
});
}
async function dragOverlayScrollbarDown(page: Page, scrollContainer: Locator): Promise<void> {
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<void> {
@@ -668,24 +417,17 @@ async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<voi
.toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
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<HTMLElement>(`[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);
}
@@ -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,
}) => {
@@ -51,6 +51,7 @@ import {
type LocalGhPrFixture,
} from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { chooseAddProjectMethod, expectAddProjectPage } from "../support/helpers/add-project-flow";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
@@ -78,8 +79,7 @@ interface WorkspaceStatusGroupEvent {
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
}
@@ -4,7 +4,7 @@ import { gotoAppShell } from "../support/helpers/app";
import {
addOfflineHostAndReload,
expectHostFilterRow,
openSidebarDisplayPreferences,
openSidebarHostFilter,
selectAllHostsFilter,
toggleHostFilter,
} from "../support/helpers/hosts";
@@ -29,7 +29,7 @@ test.describe("Sidebar host filter (multi-select)", () => {
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await openSidebarDisplayPreferences(page);
await openSidebarHostFilter(page);
await expectHostFilterRow(page, serverId);
await expectHostFilterRow(page, SECONDARY_HOST_ID);
@@ -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<number> {
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<SVGGraphicsElement>(
"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<void> {
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();
@@ -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";
@@ -6,6 +5,7 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -26,35 +26,6 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) {
return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`);
}
async function paintedSvgRight(locator: Locator): Promise<number> {
return locator.evaluate((svg) => {
let right = Number.NEGATIVE_INFINITY;
for (const child of svg.querySelectorAll<SVGGraphicsElement>(
"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<number> {
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<string> {
const created = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
@@ -116,53 +87,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-",
@@ -209,8 +133,7 @@ test.describe("Model B sidebar shape", () => {
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
// Switch to status grouping.
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
@@ -3,6 +3,7 @@ import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing
// whenever the row was unmounted — a collapsed project section being the common case. It now
@@ -39,8 +40,7 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
}
async function switchToStatusGrouping(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
}
@@ -16,6 +16,7 @@ import {
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectSubagentRowVisible,
@@ -82,8 +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-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
});
@@ -21,6 +21,7 @@ import {
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
@@ -397,8 +398,7 @@ async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId("sidebar-grouping-status").click();
await selectSidebarStatusGrouping(page);
await expect(
page
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
@@ -102,6 +102,8 @@ export async function waitForScrollableChat(
.toBeGreaterThan(input.minScrollableDistance);
}
const WHEEL_SETTLE_TIMEOUT_MS = 5_000;
export async function scrollChatAwayFromBottom(
page: Page,
input: { deltaY: number; minDistanceFromBottom: number },
@@ -111,31 +113,41 @@ export async function scrollChatAwayFromBottom(
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
const wheelSettled = scroll.evaluate(
(root: Element) =>
new Promise<void>((resolve) => {
root.addEventListener(
"wheel",
() => {
const scrollElement = root as HTMLElement;
let previousOffset = scrollElement.scrollTop;
let stableFrames = 0;
const sample = () => {
const currentOffset = scrollElement.scrollTop;
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
previousOffset = currentOffset;
if (stableFrames >= 3) {
resolve();
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
},
{ once: true },
);
}),
);
// Letting the scroll animation stop before measuring is an optimization, so it must never be
// the thing that decides the test — the assertion below is. The old wait could not say that:
// it resolved only from a `requestAnimationFrame` sampler started by a `wheel` listener, and
// rAF does not fire at all while the page is occluded, which is reachable on a CI runner and
// stalls the sampler on its first frame. A missed wheel event did the same. Either way the
// test hung until its own timeout with nothing to read. A timed sampler with a deadline
// cannot: worst case it gives up and the poll below reports what the scroll actually did.
const wheelSettled = scroll.evaluate((root: Element, timeoutMs: number) => {
const scrollElement = root as HTMLElement;
return new Promise<void>((resolve) => {
const startedAt = Date.now();
let sawWheel = false;
let previousOffset = scrollElement.scrollTop;
let stableSamples = 0;
root.addEventListener(
"wheel",
() => {
sawWheel = true;
},
{ once: true },
);
const sample = () => {
const currentOffset = scrollElement.scrollTop;
const isStable = sawWheel && Math.abs(currentOffset - previousOffset) <= 0.5;
stableSamples = isStable ? stableSamples + 1 : 0;
previousOffset = currentOffset;
if (stableSamples >= 3 || Date.now() - startedAt >= timeoutMs) {
resolve();
return;
}
setTimeout(sample, 16);
};
sample();
});
}, WHEEL_SETTLE_TIMEOUT_MS);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await wheelSettled;
@@ -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<void> {
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<void> {
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1);
+11 -36
View File
@@ -130,6 +130,13 @@ export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
});
}
// Host filters live a page below the display-preferences root, so the filter rows only exist
// after walking into that branch.
export async function openSidebarHostFilter(page: Page): Promise<void> {
await openSidebarDisplayPreferences(page);
await page.getByTestId("sidebar-display-host-filter").click();
}
// A host's filter row carries a status dot on the left next to its label.
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
@@ -144,32 +151,6 @@ export async function selectAllHostsFilter(page: Page): Promise<void> {
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<void> {
await openSettings(page);
await selectSettingsHost(page, serverId);
@@ -242,17 +223,16 @@ export async function expectNoHostBadge(page: Page, target: HostBadgeTarget): Pr
await expect(hostBadge(page, target)).toHaveCount(0, { timeout: 15_000 });
}
// The host sits on the meta line as plain secondary text, so its identity colour lives on the
// server icon alone. The label stays muted like every other item on that line — asserting a
// tinted label or a pill fill would re-assert the design the meta row replaced.
export async function expectHostBadgeTinted(
page: Page,
target: HostBadgeTarget & { color: IdentityColorName },
): Promise<void> {
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).toHaveText(target.hostName);
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
}
@@ -264,11 +244,6 @@ export async function expectHostAppearancePreview(
await expect(preview).toBeVisible();
const badge = preview.getByTestId(`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));
}
@@ -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<Box> {
return box as Box;
}
/** Leading and trailing edges of an element, in whole pixels. */
export async function readRails(locator: Locator): Promise<Rails> {
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<Rails> {
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<number> {
const [above, below] = await Promise.all([readBox(upper), readBox(lower)]);
@@ -166,6 +166,19 @@ export async function expectWorkspaceAbsentFromSidebar(
).toHaveCount(0, { timeout: 30_000 });
}
// The display-preferences menu is one row per decision, and the options sit a page below that
// row. Every caller has to walk the same path, so it lives here: when the menu's shape moves
// again, this is the only place that has to follow.
export async function openSidebarDisplayPage(page: Page, branchTestID: string): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await page.getByTestId(branchTestID).click();
}
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-grouping");
await page.getByTestId("sidebar-grouping-status").click();
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click();
}
@@ -111,6 +111,7 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
@@ -665,6 +666,7 @@ function WorkspaceRowRightGroup({
isTouchPlatform,
showShortcut,
});
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
return (
<>
@@ -676,9 +678,10 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -41,6 +41,7 @@ import {
SidebarWorkspaceTrailingActionSlot,
sidebarWorkspaceRowStyles,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
@@ -813,14 +814,16 @@ function StatusWorkspaceActionSlot({
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const kebab = useOpenKebabMenuVisibility(showKebab);
return (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
{showKebab && onArchive ? (
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -71,9 +71,18 @@ export interface SidebarWorkspaceMenuProps {
isPinned?: boolean;
onTogglePin?: () => void;
openInFileManagerPath?: string | null;
/**
* Lifted so the row that reveals the kebab can keep it mounted while its menu is up. See
* `useOpenKebabMenuVisibility`.
*/
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
interface SidebarWorkspaceMenuItemsProps extends Omit<SidebarWorkspaceMenuProps, "onArchive"> {
interface SidebarWorkspaceMenuItemsProps extends Omit<
SidebarWorkspaceMenuProps,
"onArchive" | "open" | "onOpenChange"
> {
onArchive?: () => void;
}
@@ -202,10 +211,12 @@ export function SidebarWorkspaceMenu({
isPinned,
onTogglePin,
openInFileManagerPath,
open,
onOpenChange,
}: SidebarWorkspaceMenuProps) {
const { t } = useTranslation();
return (
<DropdownMenu compactMode="sheet">
<DropdownMenu compactMode="sheet" open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={8}
style={triggerStyle}
@@ -32,6 +32,7 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
@@ -420,6 +421,7 @@ function WorkspaceRowTrailingActions({
isTouchPlatform,
showShortcut,
});
const kebab = useOpenKebabMenuVisibility(showKebabInSlot);
return (
<>
@@ -431,9 +433,10 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -0,0 +1,20 @@
import { useMemo, useState } from "react";
/**
* Keeps a row's kebab in its slot for as long as the menu it opened is up.
*
* The kebab is revealed by hover, and the surface it opens can take the pointer off the row —
* on a compact layout the sheet slides up over it. The row then reads as un-hovered, the
* trailing overlay unmounts its own trigger mid-gesture, and the open menu is left with no
* state that can close it: on a sheet that strands a full-screen backdrop over the app until
* reload. Lifting the menu's open state to the component that decides whether to render it is
* what stops the row from pulling the ground out from under it.
*/
export function useOpenKebabMenuVisibility(showKebab: boolean): {
showKebab: boolean;
menuProps: { open: boolean; onOpenChange: (open: boolean) => void };
} {
const [open, setOpen] = useState(false);
const menuProps = useMemo(() => ({ open, onOpenChange: setOpen }), [open]);
return { showKebab: showKebab || open, menuProps };
}
@@ -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<void> {
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);
@@ -17,6 +17,7 @@ import {
} from "../src/utils/run-git-command.js";
import { CheckoutDiffManager } from "../src/server/checkout-diff-manager.js";
import {
subscribeToWorkspaceFileChanges,
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
} from "../src/server/workspace-git-service.js";
@@ -94,7 +95,7 @@ function createTrackedSubscriber(failWorktreeRoot: string | null) {
if (failWorktreeRoot && path.resolve(args[0]) === failWorktreeRoot) {
throw new Error("measurement watcher setup failure");
}
const subscription = await parcelWatcher.subscribe(...args);
const subscription = await subscribeToWorkspaceFileChanges(...args);
subscriptions.add(subscription);
return {
unsubscribe: async () => {
@@ -6,7 +6,10 @@ import type pino from "pino";
import { afterEach, expect, test, vi } from "vitest";
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import {
subscribeToWorkspaceFileChanges,
WorkspaceGitServiceImpl,
} from "./workspace-git-service.js";
function createLogger(): pino.Logger {
const logger = {
@@ -83,7 +86,7 @@ test("native recursive observation updates tracked state and prunes ignored stor
events: parcelWatcher.Event[];
}> = [];
const subscribe: typeof parcelWatcher.subscribe = async (directory, callback, options) => {
const subscription = await parcelWatcher.subscribe(
const subscription = await subscribeToWorkspaceFileChanges(
directory,
(error, events) => {
deliveredEvents.push({ directory, events });
@@ -194,9 +197,19 @@ test("native recursive observation updates tracked state and prunes ignored stor
{ timeout: 5_000 },
);
// Parcel may deliver a startup batch before subscribe resolves. Let its
// already-scheduled consumer debounce finish inside the bootstrap phase.
await new Promise((resolve) => setTimeout(resolve, 250));
writeFileSync(trackedPath, "base\n");
await vi.waitFor(
() => {
const events = deliveredEvents.flatMap((batch) => batch.events);
expect(events.map((event) => event.path)).toContain(trackedPath);
expect(getCheckoutWorktreeState).toHaveBeenCalled();
expect(service.getMetrics()).toMatchObject({
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
});
},
{ timeout: 5_000 },
);
getCheckoutSnapshotFacts.mockClear();
getCheckoutStatus.mockClear();
@@ -206,7 +219,6 @@ test("native recursive observation updates tracked state and prunes ignored stor
runGitCommand.mockClear();
deliveredEvents.length = 0;
await new Promise((resolve) => setTimeout(resolve, 250));
expect(runGitCommand).not.toHaveBeenCalled();
expect(getCheckoutWorktreeState).not.toHaveBeenCalled();
expect(getCheckoutDiff, JSON.stringify(deliveredEvents)).not.toHaveBeenCalled();
@@ -19,6 +19,7 @@ import {
} from "../utils/checkout-git.js";
import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js";
import {
getWorkspaceFileWatcherBackend,
getWorkspaceGitSelfHealPhaseMs,
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
@@ -891,6 +892,20 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getWorkspaceGitSelfHealPhaseMs("/tmp/staggered-repo")).toBe(9_817);
});
test.each([
["darwin", "fs-events"],
["linux", "inotify"],
["win32", "windows"],
] as const)("uses the native %s file watcher backend", (platform, backend) => {
expect(getWorkspaceFileWatcherBackend(platform)).toBe(backend);
});
test("rejects unsupported watcher platforms instead of using backend auto-detection", () => {
expect(() => getWorkspaceFileWatcherBackend("freebsd")).toThrow(
"No native workspace file watcher configured for freebsd",
);
});
test("self-heal staggers workspaces and settles into a 120 second cadence", async () => {
vi.setSystemTime(0);
const otherRepoCwd = resolvePath("/tmp/staggered-repo");
@@ -306,6 +306,32 @@ interface WorkspaceGitServiceOptions {
deps?: Partial<WorkspaceGitServiceDependencies>;
}
export function getWorkspaceFileWatcherBackend(
platform: NodeJS.Platform,
): parcelWatcher.BackendType {
switch (platform) {
case "darwin":
return "fs-events";
case "linux":
return "inotify";
case "win32":
return "windows";
default:
throw new Error(`No native workspace file watcher configured for ${platform}`);
}
}
export function subscribeToWorkspaceFileChanges(
directory: string,
callback: parcelWatcher.SubscribeCallback,
options?: parcelWatcher.Options,
): Promise<parcelWatcher.AsyncSubscription> {
return parcelWatcher.subscribe(directory, callback, {
...options,
backend: getWorkspaceFileWatcherBackend(process.platform),
});
}
interface WorkspaceGitTarget {
cwd: string;
listeners: Set<WorkspaceGitListener>;
@@ -375,7 +401,7 @@ interface WorkspaceForgePrStatusPollTarget {
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
return {
subscribe: parcelWatcher.subscribe,
subscribe: subscribeToWorkspaceFileChanges,
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,