* 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.
211 lines
8.2 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|