* 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.
235 lines
9.3 KiB
TypeScript
235 lines
9.3 KiB
TypeScript
// Locks two behaviours that used to break together on web/desktop:
|
|
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
|
|
// MAIN pane in the background — the explorer pane is a background surface and must
|
|
// never swallow entity tabs or lose the user's focus, and
|
|
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
|
|
// interactive in the [agent | empty main | explorer] state (the reporter hit an
|
|
// app-wide lockup here with no way to close the empty pane).
|
|
import { expect, type Locator, type Page } from "@playwright/test";
|
|
import { test } from "../support/fixtures";
|
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
|
|
function visible(page: Page, testId: string): Locator {
|
|
return page.getByTestId(testId).filter({ visible: true });
|
|
}
|
|
|
|
function agentTabChip(page: Page, agentId: string): Locator {
|
|
return visible(page, `workspace-tab-agent_${agentId}`);
|
|
}
|
|
|
|
function draftTabChip(page: Page): Locator {
|
|
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
|
}
|
|
|
|
/** The explorer pane is the only tab row carrying the Changes tab. */
|
|
function explorerTabRow(page: Page): Locator {
|
|
return visible(page, "workspace-tabs-row").filter({
|
|
has: page.getByTestId("workspace-tab-working_diff"),
|
|
});
|
|
}
|
|
|
|
function mainTabRow(page: Page): Locator {
|
|
return visible(page, "workspace-tabs-row").filter({
|
|
hasNot: page.getByTestId("workspace-tab-working_diff"),
|
|
});
|
|
}
|
|
|
|
async function focusExplorerPane(page: Page): Promise<void> {
|
|
await openChangesPanel(page);
|
|
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
|
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
|
}
|
|
|
|
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
|
const chip = draftTabChip(page).first();
|
|
await chip.hover();
|
|
await page
|
|
.locator('[data-testid^="workspace-draft-close-"]')
|
|
.filter({ visible: true })
|
|
.first()
|
|
.click();
|
|
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
|
}
|
|
|
|
function emptyPaneChild(page: Page): Locator {
|
|
return page
|
|
.getByTestId("split-group-child")
|
|
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
|
|
.first();
|
|
}
|
|
|
|
async function emptyPaneBox(page: Page) {
|
|
const paneChild = emptyPaneChild(page);
|
|
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
|
const box = await paneChild.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
if (!box) throw new Error("Empty pane has no bounding box");
|
|
return box;
|
|
}
|
|
|
|
async function paneBoxContainingChip(page: Page, chip: Locator) {
|
|
const paneChild = page.getByTestId("split-group-child").filter({ has: chip }).first();
|
|
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
|
const box = await paneChild.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
if (!box) throw new Error("Pane has no bounding box");
|
|
return box;
|
|
}
|
|
|
|
/** dnd-kit pointer drag from a tab chip to an absolute point. */
|
|
async function dragChipTo(
|
|
page: Page,
|
|
chip: Locator,
|
|
target: { x: number; y: number },
|
|
): Promise<void> {
|
|
const chipBox = await chip.boundingBox();
|
|
expect(chipBox).not.toBeNull();
|
|
if (!chipBox) throw new Error("Dragged chip has no bounding box");
|
|
|
|
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
|
await page.mouse.down();
|
|
// Exceed the 8px PointerSensor activation distance before heading to the target.
|
|
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
|
await page.mouse.move(target.x, target.y, { steps: 20 });
|
|
await page.mouse.up();
|
|
}
|
|
|
|
test.describe("explorer pane tab placement", () => {
|
|
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const consoleErrors: string[] = [];
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") consoleErrors.push(message.text());
|
|
});
|
|
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${String(error)}`));
|
|
|
|
const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" });
|
|
let agentId = "";
|
|
|
|
try {
|
|
await test.step("empty workspace seeds a draft in the main pane", async () => {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
|
});
|
|
|
|
await test.step("focus the explorer pane", async () => {
|
|
await focusExplorerPane(page);
|
|
});
|
|
|
|
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
|
const agent = await workspace.client.createAgent({
|
|
provider: "mock",
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "Explorer Placement Agent",
|
|
modeId: "load-test",
|
|
model: "ten-second-stream",
|
|
// Keep the agent streaming through the drag so pane contents are live,
|
|
// matching the reporter's "spinners were still going" environment.
|
|
initialPrompt: "stream please",
|
|
});
|
|
agentId = agent.id;
|
|
await expect(agentTabChip(page, agentId).first()).toBeVisible({ timeout: 30_000 });
|
|
|
|
await expect(
|
|
mainTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
|
).toBeVisible();
|
|
await expect(
|
|
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
|
).toHaveCount(0);
|
|
// The background open must not steal the explorer pane's focus.
|
|
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
|
await closeSeededDraftInMainPane(page);
|
|
await expect(
|
|
mainTabRow(page).first().locator('[data-testid^="workspace-tab-"]'),
|
|
).toHaveCount(1);
|
|
await testInfo.attach("before-drag", {
|
|
body: await page.screenshot(),
|
|
contentType: "image/png",
|
|
});
|
|
});
|
|
|
|
await test.step("split the agent out of the main pane, leaving it empty", async () => {
|
|
const chip = agentTabChip(page, agentId).first();
|
|
const mainBox = await paneBoxContainingChip(page, chip);
|
|
// Land inside the left 15% edge band of the main pane content.
|
|
await dragChipTo(page, chip, {
|
|
x: mainBox.x + mainBox.width * 0.06,
|
|
y: mainBox.y + mainBox.height * 0.6,
|
|
});
|
|
await testInfo.attach("after-split", {
|
|
body: await page.screenshot(),
|
|
contentType: "image/png",
|
|
});
|
|
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
|
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
|
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
|
|
});
|
|
|
|
await test.step("app must stay interactive after the split", async () => {
|
|
// JS main thread alive?
|
|
const pong = await page.evaluate("1 + 1");
|
|
expect(pong).toBe(2);
|
|
|
|
// Pointer interaction alive? Selecting the agent chip must still work.
|
|
const chip = agentTabChip(page, agentId).first();
|
|
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
|
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
|
|
|
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
|
|
// above the vertically centred launcher, which would open a tab instead.
|
|
const emptyPane = emptyPaneChild(page);
|
|
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
|
|
|
// The empty pane's + menu must open and dismiss cleanly.
|
|
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
|
await emptyPaneTrigger.click({ timeout: 5_000 });
|
|
await page.keyboard.press("Escape");
|
|
|
|
// A second drag must work: center-drop the agent tab back into the empty pane.
|
|
const target = await emptyPaneBox(page);
|
|
await dragChipTo(page, chip, {
|
|
x: target.x + target.width / 2,
|
|
y: target.y + target.height / 2,
|
|
});
|
|
await testInfo.attach("after-center-drop", {
|
|
body: await page.screenshot(),
|
|
contentType: "image/png",
|
|
});
|
|
|
|
// The agent chip must have moved into the previously empty pane and stay clickable.
|
|
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
|
|
timeout: 10_000,
|
|
});
|
|
await agentTabChip(page, agentId)
|
|
.first()
|
|
.click({ position: { x: 12, y: 13 } });
|
|
|
|
// The explorer toggle must still respond.
|
|
await visible(page, "workspace-explorer-toggle").first().click({ timeout: 5_000 });
|
|
await testInfo.attach("after-interactions", {
|
|
body: await page.screenshot(),
|
|
contentType: "image/png",
|
|
});
|
|
});
|
|
|
|
await testInfo.attach("console-errors", {
|
|
body: JSON.stringify(consoleErrors, null, 2),
|
|
contentType: "application/json",
|
|
});
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|