Files
paseo/packages/app/e2e/browser/workspace-pane-remount.spec.ts
T
Mohamed BoudraandGitHub 19a7d26349 feat(app): make the side panel user-directed (#3605)
* 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.
2026-08-20 21:49:23 +02:00

211 lines
8.2 KiB
TypeScript

import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { expectComposerVisible } from "../support/helpers/composer";
import { clickNewTerminal, terminalSurfaceLocator } from "../support/helpers/launcher";
import { renameModalInput } from "../support/helpers/rename";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { clickSettingsBackToWorkspace, openCompactSettings } from "../support/helpers/settings";
import { openSettings } from "../support/helpers/app";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
clickFirstTerminalTab,
waitForWorkspaceTabsVisible,
} from "../support/helpers/workspace-tabs";
import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf";
async function captureRenderedNode(locator: Locator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
const node = await locator.elementHandle();
if (!node) {
throw new Error("Expected rendered node");
}
return node;
}
async function expectSameRenderedNode(
original: Awaited<ReturnType<typeof captureRenderedNode>>,
locator: Locator,
) {
const current = await captureRenderedNode(locator);
await expect(original.evaluate((node, candidate) => node === candidate, current)).resolves.toBe(
true,
);
}
test.describe("Workspace pane mounting", () => {
test("opening the first split pane keeps the existing agent composer mounted", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `pane-remount-${Date.now()}`,
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
await expectComposerVisible(page);
const originalComposer = await page
.getByTestId("message-input-root")
.filter({ visible: true })
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
expect(originalStillConnected).toBe(true);
} finally {
await workspace.cleanup();
}
});
test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-pane-retention-${Date.now()}`,
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer);
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
await tab.click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
await expect(renameInput).toBeVisible();
const settingsShortcut = await page.evaluate(() =>
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
);
await page.keyboard.press(settingsShortcut);
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(renameInput).not.toBeVisible();
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalComposer, composer);
} finally {
await workspace.cleanup();
}
});
test("opening Settings on a compact layout keeps the agent composer mounted", async ({
page,
}) => {
test.setTimeout(90_000);
await page.setViewportSize({ width: 480, height: 900 });
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "compact-settings-pane-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `compact-settings-pane-retention-${Date.now()}`,
});
const agentRoute = buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId);
await page.goto(agentRoute);
await expectComposerVisible(page);
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
const originalComposer = await captureRenderedNode(composer);
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId));
await page.goBack();
await page.waitForURL(
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
);
await expectSameRenderedNode(originalComposer, composer);
} finally {
await workspace.cleanup();
}
});
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `terminal-pane-retention-${Date.now()}`,
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
const terminalSurface = terminalSurfaceLocator(page);
const originalTerminal = await captureRenderedNode(terminalSurface);
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
await expectComposerVisible(page);
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
await clickFirstTerminalTab(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
} finally {
await workspace.cleanup();
}
});
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
try {
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-terminal-retention-${Date.now()}`,
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await waitForWorkspaceTabsVisible(page);
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
const terminalSurface = terminalSurfaceLocator(page);
const originalTerminal = await captureRenderedNode(terminalSurface);
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expectSameRenderedNode(originalTerminal, terminalSurface);
} finally {
await workspace.cleanup();
}
});
});