* feat(app): let the file panel close its tree rail The file panel's tree rail was permanent on desktop — the changes panel had a folder-tree toggle in its toolbar, the file toolbar had nothing, so the rail was there whether you wanted it or not. The changes panel's private DesktopTreeToggle becomes a shared TreeRailToggle so both toolbars render the same affordance; that also drops the treeToggleStyle prop threaded through ChangesToolbarProps, which recomputed exactly what the component already built internally. The i18n keys move out of workspace.git.diff into workspace.tree, since a shared component reaching into the git namespace would rot. The two rails keep independent flags — the file rail in panel-store, the changes rail in useChangesPreferences — so closing one leaves the other alone. FileTreeToggle wires itself to the store rather than taking a callback: the bar renders several components below FilePanel, where the rail actually lives. Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum). The persist version bump resets treeRailWidth on purpose: nearly every existing install carries the old 320 default, so without the reset the narrower rail would only ever reach new users. Anyone who dragged their rail to a deliberate custom width loses it once. Note that MIN_CONTENT_WIDTH still clamps the rail to `containerWidth - 240`, so in a narrow pane the new default never applies — that floor, not the default, is what sets the width there. * feat(app): make the side panel an empty workspace surface The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference. * test(app): exercise side panel policy through its capability boundary The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module. * test(app): expect an empty side panel after upgrade * test(app): expect an empty first split pane * test(app): account for the empty side panel in split retention * feat(app): make workspace panel tabs independent Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions. * fix(app): preserve file conflicts during preview refresh Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
import path from "node:path";
|
|
import { test, expect } from "../support/fixtures";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import {
|
|
closeMobileAgentSidebar,
|
|
expectMobileAgentSidebarHidden,
|
|
expectMobileAgentSidebarVisible,
|
|
openMobileAgentSidebar,
|
|
pinWorkspaceFromSidebar,
|
|
} from "../support/helpers/sidebar";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
|
import { escapeRegex } from "../support/helpers/regex";
|
|
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
|
|
|
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
|
|
|
function getWorkspaceRowTestId(workspaceId: string): string {
|
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
}
|
|
|
|
async function openWorkspaceFromSidebar(
|
|
page: import("@playwright/test").Page,
|
|
workspaceId: string,
|
|
) {
|
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.click();
|
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
|
return row;
|
|
}
|
|
|
|
async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
|
|
const row = page
|
|
.getByRole("button", {
|
|
name: new RegExp(escapeRegex(projectName), "i"),
|
|
})
|
|
.first();
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
return row;
|
|
}
|
|
|
|
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
|
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
return row;
|
|
}
|
|
|
|
async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) {
|
|
const row = await waitForSidebarWorkspace(page, workspaceId);
|
|
await row.hover();
|
|
|
|
const hoverCard = page.getByRole("menu", { name: "Workspace scripts" });
|
|
await expect(hoverCard).toBeVisible({ timeout: 30_000 });
|
|
return hoverCard;
|
|
}
|
|
|
|
interface PaseoOwnedWorktree {
|
|
projectName: string;
|
|
workspaceId: string;
|
|
worktreeSlug: string;
|
|
}
|
|
|
|
async function withPaseoOwnedWorktree(
|
|
run: (workspace: PaseoOwnedWorktree) => Promise<void>,
|
|
): Promise<void> {
|
|
const project = await seedWorkspace({ repoPrefix: "sidebar-hover-owned-worktree-" });
|
|
const worktreeSlug = "hover-card-owned-worktree";
|
|
|
|
try {
|
|
const created = await project.client.createWorkspace({
|
|
source: {
|
|
kind: "worktree",
|
|
cwd: project.repoPath,
|
|
projectId: project.projectId,
|
|
worktreeSlug,
|
|
},
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? "Failed to create Paseo-owned worktree");
|
|
}
|
|
expect(path.basename(created.workspace.workspaceDirectory)).toBe(worktreeSlug);
|
|
|
|
await run({
|
|
projectName: path.basename(project.repoPath),
|
|
workspaceId: created.workspace.id,
|
|
worktreeSlug,
|
|
});
|
|
} finally {
|
|
await project.cleanup();
|
|
}
|
|
}
|
|
|
|
test.describe("Sidebar workspace list", () => {
|
|
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "sidebar-remote-",
|
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
|
});
|
|
|
|
try {
|
|
const projectName = path.basename(workspace.repoPath);
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, projectName);
|
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
|
|
const projectRow = page
|
|
.locator('[data-testid^="sidebar-project-row-"]')
|
|
.filter({ hasText: projectName })
|
|
.first();
|
|
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("project shows workspace under it", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("non-git project shows directory name", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
|
|
const directoryName = path.basename(workspace.repoPath);
|
|
const projectRow = await waitForSidebarProject(page, directoryName);
|
|
await expect(projectRow).toContainText(directoryName);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "sidebar-header-",
|
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
|
});
|
|
|
|
try {
|
|
const projectName = path.basename(workspace.repoPath);
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, projectName);
|
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
|
|
await expectWorkspaceHeader(page, {
|
|
title: workspace.workspaceName,
|
|
subtitle: projectName,
|
|
});
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("git project shows branch name in workspace row", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
|
|
expect(workspace.workspaceName).toBe("main");
|
|
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
|
|
"main",
|
|
);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("workspace hover card shows host as metadata", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
|
|
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
|
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
|
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => {
|
|
await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, projectName);
|
|
await openWorkspaceHoverCard(page, workspaceId);
|
|
|
|
await expect(page.getByTestId("hover-card-workspace-cwd")).toHaveText(worktreeSlug);
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe("Mobile sidebar panelState transition", () => {
|
|
test.use({ viewport: { width: 390, height: 844 } });
|
|
|
|
test("showMobileAgent open and close transition", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
await expectMobileAgentSidebarHidden(page);
|
|
await openMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarVisible(page);
|
|
await closeMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarHidden(page);
|
|
});
|
|
|
|
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
|
|
|
|
try {
|
|
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);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("Half-screen desktop layout", () => {
|
|
test.use({ viewport: { width: 751, height: 982 } });
|
|
|
|
test("keeps the sidebar scroll position across close and reopen", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-scroll-" });
|
|
|
|
try {
|
|
let lastWorkspaceId = workspace.workspaceId;
|
|
for (let index = 0; index < 24; index += 1) {
|
|
const created = await workspace.client.createWorkspace({
|
|
source: {
|
|
kind: "directory",
|
|
path: workspace.repoPath,
|
|
projectId: workspace.projectId,
|
|
},
|
|
title: `Retained sidebar ${index + 1}`,
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? "Failed to fill the retained sidebar");
|
|
}
|
|
lastWorkspaceId = created.workspace.id;
|
|
}
|
|
|
|
await gotoAppShell(page);
|
|
await page
|
|
.getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(workspace.projectKey)}`)
|
|
.click();
|
|
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
|
|
|
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
|
const scrollTop = await sidebarScroll.evaluate((element) => {
|
|
element.scrollTop = 160;
|
|
return element.scrollTop;
|
|
});
|
|
expect(scrollTop).toBe(160);
|
|
|
|
await page.getByTestId("menu-button").click();
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
|
|
|
await page.getByTestId("menu-button").click();
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
|
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
|
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
|
});
|
|
|
|
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
|
|
const openToggle = page.getByTestId("menu-button");
|
|
const openIcon = openToggle.locator("svg").first();
|
|
await expect(openIcon).toBeVisible();
|
|
const openBounds = await openIcon.boundingBox();
|
|
expect(openBounds).not.toBeNull();
|
|
expect(openBounds?.x).toBeGreaterThan(12);
|
|
|
|
await openToggle.click();
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
|
|
|
const closedToggle = page.getByTestId("menu-button");
|
|
const closedIcon = closedToggle.locator("svg").first();
|
|
await expect(closedIcon).toBeVisible();
|
|
const closedBounds = await closedIcon.boundingBox();
|
|
expect(closedBounds).not.toBeNull();
|
|
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
|
expect(closedBounds?.y).toBe(openBounds?.y);
|
|
});
|
|
|
|
test("yields app navigation to the settings split", async ({ page }) => {
|
|
await gotoAppShell(page);
|
|
await page.getByTestId("sidebar-settings").click();
|
|
|
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
|
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
|
|
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
|
});
|
|
|
|
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
|
|
|
try {
|
|
await gotoAppShell(page);
|
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
|
|
await openFilesPanel(page);
|
|
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
|
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
|
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
|
|
|
await explorerToggle.click();
|
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
|
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|