diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index 184c2cf93e..b617f79f21 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -6,11 +6,8 @@ // interactive in the [agent | New | explorer] state. import { expect, type Locator, type Page } from "@playwright/test"; import { test } from "../support/fixtures"; -import { z } from "zod"; -import { WorkspaceLayoutPersistedStateSchema } from "../../src/stores/workspace-layout-storage"; -import type { WorkspaceTab } from "../../src/workspace-tabs/model"; import { gotoWorkspace } from "../support/helpers/launcher"; -import { seedWorkspace } from "../support/helpers/seed-client"; +import { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout"; import { openChangesTreePanel, waitForWorkspaceTabsVisible, @@ -96,134 +93,137 @@ async function dragChipTo( await page.mouse.up(); } +function collectConsoleErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(`pageerror: ${String(error)}`)); + return errors; +} + test.describe("explorer pane tab placement", () => { test("agents open in the main pane and New-tab splits keep the app usable", async ({ page, + withWorkspace, + e2eWorkerClient, }, 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 consoleErrors = collectConsoleErrors(page); - const workspace = await seedWorkspace({ repoPrefix: "explorer-pane-placement-" }); + const workspace = await withWorkspace({ prefix: "explorer-pane-placement-" }); let agentId = ""; - try { - await test.step("choose Agent from the empty workspace launcher", async () => { - await gotoWorkspace(page, workspace.workspaceId); - await waitForWorkspaceTabsVisible(page); - await openAgentDraftFromLauncher(page); - await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 }); + await test.step("choose Agent from the empty workspace launcher", async () => { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await openAgentDraftFromLauncher(page); + await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 }); + }); + + await test.step("select Changes in Explorer", async () => { + await selectExplorerChanges(page); + }); + + await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => { + const agent = await e2eWorkerClient.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 change the selected Explorer view. + await expect(visible(page, "changes-tree-panel").first()).toBeVisible(); + }); + + 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 + New main. Explorer keeps its own rail. + await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 }); + await expect(explorerTabRow(page)).toHaveCount(1); + await expect(page.getByTestId("workspace-new-tab-panel")).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 New tab pane (focuses it) must not lock anything. Aim + // above the vertically centred launcher, which would open a tab instead. + const emptyPane = newTabPaneChild(page); + await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 }); + + // A second drag must work: center-drop the agent tab back into the New 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", }); - await test.step("select Changes in Explorer", async () => { - await selectExplorerChanges(page); + // The agent chip must have replaced New in that pane and stay clickable. + await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, { + timeout: 10_000, }); + await agentTabChip(page, agentId) + .first() + .click({ position: { x: 12, y: 13 } }); - 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 change the selected Explorer view. - await expect(visible(page, "changes-tree-panel").first()).toBeVisible(); + // 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 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 + New main. Explorer keeps its own rail. - await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 }); - await expect(explorerTabRow(page)).toHaveCount(1); - await expect(page.getByTestId("workspace-new-tab-panel")).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 New tab pane (focuses it) must not lock anything. Aim - // above the vertically centred launcher, which would open a tab instead. - const emptyPane = newTabPaneChild(page); - await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 }); - - // A second drag must work: center-drop the agent tab back into the New 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 replaced New in that pane and stay clickable. - await expect(page.getByTestId("workspace-new-tab-panel")).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(); - } + await testInfo.attach("console-errors", { + body: JSON.stringify(consoleErrors, null, 2), + contentType: "application/json", + }); }); }); @@ -261,106 +261,47 @@ async function openAgentDraftFromLauncher(page: Page): Promise { await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); } -async function persistHiddenGeneratedExplorer(page: Page): Promise { - const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); - if (raw === null) throw new Error("Explorer fixture: workspace layout was not persisted"); - const stored = z - .strictObject({ - state: WorkspaceLayoutPersistedStateSchema, - version: z.number().int().nonnegative().optional(), - }) - .parse(JSON.parse(raw), { - error: () => "Explorer fixture: invalid persisted workspace layout", - }); - const [key] = Object.keys(stored.state.layoutByWorkspace); - if (!key) throw new Error("Explorer fixture: persisted layout contains no workspace"); - const tabs: WorkspaceTab[] = [ - { tabId: "files", target: { kind: "files" }, createdAt: 1 }, - { tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 }, - ...Array.from( - { length: 2250 }, - (_, index): WorkspaceTab => ({ - tabId: `draft_saved_${index}`, - target: { kind: "draft", draftId: `draft_saved_${index}` }, - createdAt: index + 2, - }), - ), - ]; - const corruptedLayout = { - root: { - kind: "pane", - pane: { - id: "pane_generated_report_equivalent", - hidden: true, - tabIds: tabs.map((tab) => tab.tabId), - tabs, - focusedTabId: tabs[tabs.length - 1].tabId, - }, - }, - focusedPaneId: null, - }; - stored.state = WorkspaceLayoutPersistedStateSchema.parse({ - ...stored.state, - layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: corruptedLayout }, - explorerPaneIdByWorkspace: { - ...stored.state.explorerPaneIdByWorkspace, - [key]: "pane_generated_report_equivalent", - }, - }); - await page.evaluate( - (value) => localStorage.setItem("workspace-layout-state", value), - JSON.stringify(stored), - ); -} - // Explorer cannot replace the ordinary workspace canvas, including on restore. test("closing the last split-born tab with hidden Explorer keeps a usable workspace", async ({ page, + withWorkspace, }) => { - const workspace = await seedWorkspace({ repoPrefix: "last-pane-hidden-explorer-" }); - try { - await gotoWorkspace(page, workspace.workspaceId); - await openAgentDraftFromLauncher(page); - await closeOnlyDraft(page); - await expectNewLauncher(page); - await openAgentDraftFromLauncher(page); - await moveOnlyDraftIntoRightSplit(page); - await closeOnlyDraft(page); - await expectNewLauncher(page); - await page.reload(); - await expectNewLauncher(page); - } finally { - await workspace.cleanup(); - } + const workspace = await withWorkspace({ prefix: "last-pane-hidden-explorer-" }); + await gotoWorkspace(page, workspace.workspaceId); + await openAgentDraftFromLauncher(page); + await closeOnlyDraft(page); + await expectNewLauncher(page); + await openAgentDraftFromLauncher(page); + await moveOnlyDraftIntoRightSplit(page); + await closeOnlyDraft(page); + await expectNewLauncher(page); + await page.reload(); + await expectNewLauncher(page); }); -test("visible Explorer does not replace the last ordinary workspace pane", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "last-pane-visible-explorer-" }); - try { - await gotoWorkspace(page, workspace.workspaceId); - await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click(); - await openAgentDraftFromLauncher(page); - await moveOnlyDraftIntoRightSplit(page); - await closeOnlyDraft(page); - await expectNewLauncher(page); - await page.reload(); - await expectNewLauncher(page); - } finally { - await workspace.cleanup(); - } +test("visible Explorer does not replace the last ordinary workspace pane", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "last-pane-visible-explorer-" }); + await gotoWorkspace(page, workspace.workspaceId); + await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click(); + await openAgentDraftFromLauncher(page); + await moveOnlyDraftIntoRightSplit(page); + await closeOnlyDraft(page); + await expectNewLauncher(page); + await page.reload(); + await expectNewLauncher(page); }); test("reloading a saved hidden generated Explorer recovers a usable workspace", async ({ page, + withWorkspace, }) => { - const workspace = await seedWorkspace({ repoPrefix: "saved-hidden-explorer-" }); - try { - await gotoWorkspace(page, workspace.workspaceId); - await expectNewLauncher(page); - await persistHiddenGeneratedExplorer(page); - await page.reload(); - await expectNewLauncher(page); - } finally { - await workspace.cleanup(); - } + const workspace = await withWorkspace({ prefix: "saved-hidden-explorer-" }); + await gotoWorkspace(page, workspace.workspaceId); + await expectNewLauncher(page); + await seedCorruptedWorkspaceLayout(page); + await page.reload(); + await expectNewLauncher(page); }); diff --git a/packages/app/e2e/support/helpers/workspace-layout.ts b/packages/app/e2e/support/helpers/workspace-layout.ts new file mode 100644 index 0000000000..33a7213035 --- /dev/null +++ b/packages/app/e2e/support/helpers/workspace-layout.ts @@ -0,0 +1,57 @@ +import type { Page } from "@playwright/test"; +import { z } from "zod"; +import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage"; +import type { WorkspaceTab } from "../../../src/workspace-tabs/model"; + +/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */ +export async function seedCorruptedWorkspaceLayout(page: Page): Promise { + const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); + if (raw === null) throw new Error("Explorer fixture: workspace layout was not persisted"); + const stored = z + .strictObject({ + state: WorkspaceLayoutPersistedStateSchema, + version: z.number().int().nonnegative().optional(), + }) + .parse(JSON.parse(raw), { + error: () => "Explorer fixture: invalid persisted workspace layout", + }); + const [key] = Object.keys(stored.state.layoutByWorkspace); + if (!key) throw new Error("Explorer fixture: persisted layout contains no workspace"); + const tabs: WorkspaceTab[] = [ + { tabId: "files", target: { kind: "files" }, createdAt: 1 }, + { tabId: "changes_tree", target: { kind: "changes_tree" }, createdAt: 1 }, + ...Array.from( + { length: 2250 }, + (_, index): WorkspaceTab => ({ + tabId: `draft_saved_${index}`, + target: { kind: "draft", draftId: `draft_saved_${index}` }, + createdAt: index + 2, + }), + ), + ]; + const corruptedLayout = { + root: { + kind: "pane", + pane: { + id: "pane_generated_report_equivalent", + hidden: true, + tabIds: tabs.map((tab) => tab.tabId), + tabs, + focusedTabId: tabs[tabs.length - 1].tabId, + }, + }, + focusedPaneId: null, + }; + stored.state = WorkspaceLayoutPersistedStateSchema.parse({ + ...stored.state, + layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: corruptedLayout }, + explorerPaneIdByWorkspace: { + ...stored.state.explorerPaneIdByWorkspace, + [key]: "pane_generated_report_equivalent", + }, + }); + await page.evaluate( + (value) => localStorage.setItem("workspace-layout-state", value), + JSON.stringify(stored), + ); +}