Merge remote-tracking branch 'origin/main' into ci/simplify-test-suite

This commit is contained in:
Mohamed Boudra
2026-09-15 02:31:05 +02:00
21 changed files with 930 additions and 547 deletions
@@ -1,5 +1,5 @@
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -12,6 +12,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
await page.setViewportSize({ width: 1400, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await page.keyboard.press(CHANGES_SHORTCUT);
@@ -70,6 +70,7 @@ test.describe("Composer control density across tab switches", () => {
});
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await clickNewChat(page);
const draftTabs = page
.locator('[data-testid^="workspace-tab-draft"]')
@@ -1,6 +1,10 @@
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
import {
clickNewChat,
gotoWorkspace,
pressDirectNewTabShortcut,
} from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -11,6 +15,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const focusedPaneChild = page
@@ -1,7 +1,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
@@ -43,6 +43,7 @@ test.describe("New tab keyboard launcher", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleNewTabPanel(page);
@@ -7,7 +7,7 @@
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 { seedCorruptedWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
openChangesTreePanel,
waitForWorkspaceTabsVisible,
@@ -93,132 +93,221 @@ 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("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("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",
});
});
});
async function closeOnlyDraft(page: Page): Promise<void> {
await draftTabChip(page).hover();
await page.getByRole("button", { name: "Close", exact: true }).click();
}
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
await page.getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
const target = await emptyPaneBox(page);
await dragChipTo(page, draftTabChip(page), {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
}
async function expectNewLauncher(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-panel").getByRole("button", { name: "Agent", exact: true }),
).toBeVisible();
await expect(draftTabChip(page)).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
}
async function openAgentDraftFromLauncher(page: Page): Promise<void> {
await expectNewLauncher(page);
await page
.getByTestId("workspace-new-tab-panel")
.getByRole("button", { name: "Agent", exact: true })
.click();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
// 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 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,
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 otherWorkspace = await withWorkspace({ prefix: "uncorrupted-explorer-" });
await gotoWorkspace(page, otherWorkspace.workspaceId);
await openAgentDraftFromLauncher(page);
const workspace = await withWorkspace({ prefix: "saved-hidden-explorer-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectNewLauncher(page);
await seedCorruptedWorkspaceLayout(page, workspace.workspaceId);
await page.reload();
await expectNewLauncher(page);
await gotoWorkspace(page, otherWorkspace.workspaceId);
await expect(draftTabChip(page)).toHaveCount(1);
});
@@ -247,6 +247,7 @@ catalogTest(
await test.step("open a draft with a one-row runtime catalog", async () => {
await setRuntimeCatalog(client, 1, workspace.repoPath);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
});
await test.step("open New Agent after the provider publishes repeated model rows", async () => {
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace } from "../support/helpers/seed-client";
@@ -132,13 +133,14 @@ test.describe("Workspace navigation regression", () => {
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "workspace-settings-back-tab-" });
await workspace.navigateTo();
await clickNewChat(page);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await openSettings(page);
@@ -148,7 +150,12 @@ test.describe("Workspace navigation regression", () => {
await closeFirstVisibleDraftTab(page);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(0);
await expect(
page
.getByTestId("workspace-new-tab-panel")
.getByRole("button", { name: "Agent", exact: true }),
).toBeVisible();
});
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
@@ -0,0 +1,63 @@
import type { Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
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 = buildWorkspaceTabPersistenceKey({ serverId: getServerId(), workspaceId });
if (!key || !Object.hasOwn(stored.state.layoutByWorkspace, key)) {
throw new Error(`Explorer fixture: no persisted layout for workspace ${workspaceId}`);
}
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),
);
}
@@ -740,6 +740,7 @@ function TabChip({
dragHandleProps: DraggableListDragHandleProps | undefined;
}) {
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const { t } = useTranslation();
const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
);
@@ -889,6 +890,8 @@ function TabChip({
<Pressable
{...(closeButtonDragBlockers as object | undefined)}
testID={closeButtonTestId}
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.menu.close")}
disabled={isClosingTab}
onPressIn={handleCloseButtonPressIn}
onHoverIn={handleCloseButtonHoverIn}
@@ -11,7 +11,6 @@ import {
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import { createNewWorkspaceTab } from "@/workspace-tabs/new-tab";
import { generateDraftId } from "@/stores/draft-keys";
export interface SplitPane {
id: string;
@@ -145,12 +144,16 @@ interface OpenTabInLayoutResult {
}
interface RetargetTabInLayoutInput {
explorerSidebarPaneId: string | null;
layout: WorkspaceLayout;
tabId: string;
target: WorkspaceTabTarget;
}
interface ReplaceTabTargetInLayoutInput extends RetargetTabInLayoutInput {
interface ReplaceTabTargetInLayoutInput extends Omit<
RetargetTabInLayoutInput,
"explorerSidebarPaneId"
> {
createTabId: () => string;
state?: JsonValue;
}
@@ -161,6 +164,7 @@ interface RetargetTabInLayoutResult {
}
interface ConvertDraftToAgentInLayoutInput {
explorerSidebarPaneId: string | null;
layout: WorkspaceLayout;
tabId: string;
agentId: string;
@@ -179,7 +183,7 @@ interface ReorderFocusedPaneTabsInLayoutInput {
interface CloseTabInLayoutInput {
layout: WorkspaceLayout;
tabId: string;
preserveEmptyPaneId?: string | null;
explorerSidebarPaneId: string | null;
}
interface ClosePaneInLayoutInput {
@@ -1157,14 +1161,54 @@ function restoreEmptyPanesInNode(
});
}
export function restoreEmptyPanesInLayout(
function findOrdinaryPane(
root: SplitNodeInternal,
explorerSidebarPaneId: string | null,
): SplitPaneInternal | null {
if (root.kind === "pane") return root.pane.id === explorerSidebarPaneId ? null : root.pane;
for (const child of root.group.children) {
const pane = findOrdinaryPane(child, explorerSidebarPaneId);
if (pane) return pane;
}
return null;
}
/** Restores the workspace canvas and empty tabs without changing Explorer or saved content. */
export function restoreWorkspaceLayout(
layout: WorkspaceLayout,
explorerSidebarPaneId: string | null = null,
explorerSidebarPaneId: string | null,
createNodeId = defaultWorkspaceLayoutIds.createNodeId,
): WorkspaceLayout {
const normalized = normalizeLayout(layout);
let root = asInternalNode(normalized.root);
let focusedPaneId = normalized.focusedPaneId;
if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) {
const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId);
if (ordinaryPane) {
root = updatePaneInTree(root, {
paneId: ordinaryPane.id,
updater: (pane) => ({ ...pane, hidden: false }),
});
focusedPaneId = ordinaryPane.id;
} else {
// Explorer may have a generated persisted id; its role, not its spelling,
// determines whether it can serve as the workspace canvas.
focusedPaneId = DEFAULT_PANE_ID;
if (explorerSidebarPaneId === DEFAULT_PANE_ID) {
focusedPaneId = createNodeId("pane");
}
root = createGroupNode({
id: createNodeId("group"),
direction: "horizontal",
children: [createPaneNode({ id: focusedPaneId, tabs: [] }), root],
sizes: [0.78, 0.22],
});
}
}
return {
...normalized,
root: restoreEmptyPanesInNode(asInternalNode(normalized.root), explorerSidebarPaneId),
focusedPaneId,
root: restoreEmptyPanesInNode(root, explorerSidebarPaneId),
};
}
@@ -1470,8 +1514,11 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
return null;
}
const preserveEmptyPaneId =
input.preserveEmptyPaneId ??
(pane.id === DEFAULT_PANE_ID || pane.id === EXPLORER_SIDEBAR_PANE_ID ? pane.id : null);
pane.id === DEFAULT_PANE_ID ||
pane.id === input.explorerSidebarPaneId ||
isLastVisibleOrdinaryPane(input.layout, pane.id, input.explorerSidebarPaneId)
? pane.id
: null;
const closeSuccessorTabId = getCloseSuccessorTabId({
pane,
@@ -1514,6 +1561,17 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout
return nextLayoutWithParentMap;
}
function isLastVisibleOrdinaryPane(
layout: WorkspaceLayout,
paneId: string,
explorerSidebarPaneId: string | null | undefined,
): boolean {
const ordinaryPanes = collectAllPanes(layout.root).filter(
(pane) => pane.id !== explorerSidebarPaneId,
);
return ordinaryPanes.length === 1 && ordinaryPanes[0].id === paneId;
}
/**
* Whether dismissing this pane would do anything — removing it, or hiding it if it
* is the Explorer sidebar. A workspace always has somewhere to look, so the last pane the
@@ -1533,10 +1591,9 @@ export function canDismissPaneInLayout(
if (!pane || pane.hidden === true) {
return false;
}
const ordinaryPanes = collectAllPanes(layout.root).filter(
(candidate) => candidate.id !== explorerSidebarPaneId,
);
return paneId === explorerSidebarPaneId ? ordinaryPanes.length > 0 : ordinaryPanes.length > 1;
return paneId === explorerSidebarPaneId
? collectAllPanes(layout.root).some((candidate) => candidate.id !== explorerSidebarPaneId)
: !isLastVisibleOrdinaryPane(layout, paneId, explorerSidebarPaneId);
}
/**
@@ -1553,14 +1610,7 @@ export function closePaneInLayout(input: ClosePaneInLayoutInput): WorkspaceLayou
if (!panePath) {
return null;
}
const visibleOrdinaryPaneIds = listPaneIds(layout.root).filter(
(paneId) => paneId !== input.explorerSidebarPaneId,
);
if (
input.paneId !== input.explorerSidebarPaneId &&
visibleOrdinaryPaneIds.length <= 1 &&
visibleOrdinaryPaneIds.includes(input.paneId)
) {
if (isLastVisibleOrdinaryPane(input.layout, input.paneId, input.explorerSidebarPaneId)) {
return null;
}
@@ -1643,6 +1693,7 @@ export function retargetTabInLayout(
closeTabInLayout({
layout: input.layout,
tabId: input.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? input.layout;
return {
layout:
@@ -1806,6 +1857,7 @@ export function convertDraftToAgentInLayout(
closeTabInLayout({
layout: input.layout,
tabId: input.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? input.layout;
return {
layout:
@@ -2275,6 +2327,7 @@ function collapseStaleEntityTabs(input: {
snapshot: WorkspaceTabSnapshot;
visibleAgentIds: Set<string>;
knownTerminalIds: Set<string>;
explorerSidebarPaneId: string | null;
}): WorkspaceLayout {
const { snapshot, visibleAgentIds, knownTerminalIds } = input;
let nextLayout = input.layout;
@@ -2284,6 +2337,7 @@ function collapseStaleEntityTabs(input: {
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? nextLayout;
}
if (
@@ -2295,6 +2349,7 @@ function collapseStaleEntityTabs(input: {
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
explorerSidebarPaneId: input.explorerSidebarPaneId,
}) ?? nextLayout;
}
}
@@ -2360,43 +2415,6 @@ function addMissingEntityTabs(input: {
return nextLayout;
}
function seedDraftForEmptyWorkspace(input: {
layout: WorkspaceLayout;
snapshot: WorkspaceTabSnapshot;
activeAgentIds: Set<string>;
knownTerminalIds: Set<string>;
explorerSidebarPaneId: string | null;
}): WorkspaceLayout {
const ready = input.snapshot.agentsHydrated && input.snapshot.terminalsHydrated;
const creatingContent =
input.snapshot.hasActivePendingDraftCreate === true ||
input.snapshot.hasActivePendingTerminalCreate === true;
const hasWorkspaceEntities = input.activeAgentIds.size > 0 || input.knownTerminalIds.size > 0;
const explorerTabIds = new Set(
input.explorerSidebarPaneId
? (findPaneById(input.layout.root, input.explorerSidebarPaneId)?.tabIds ?? [])
: [],
);
const hasContentTab = collectAllTabs(input.layout.root).some(
(tab) => tab.target.kind !== "new_tab" && !explorerTabIds.has(tab.tabId),
);
if (!ready || creatingContent || hasWorkspaceEntities || hasContentTab) {
return input.layout;
}
const draftId = generateDraftId();
return (
createTabInLayout({
layout: input.layout,
target: { kind: "draft", draftId },
now: Date.now(),
placement: FOCUSED_PANE_PLACEMENT,
explorerSidebarPaneId: input.explorerSidebarPaneId,
createTabId: () => draftId,
})?.layout ?? input.layout
);
}
export function reconcileWorkspaceTabs(
state: WorkspaceTabReconcileState,
snapshot: WorkspaceTabSnapshot,
@@ -2465,6 +2483,7 @@ export function reconcileWorkspaceTabs(
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
explorerSidebarPaneId: state.explorerSidebarPaneId,
}) ?? nextLayout;
}
}
@@ -2474,6 +2493,7 @@ export function reconcileWorkspaceTabs(
snapshot,
visibleAgentIds,
knownTerminalIds,
explorerSidebarPaneId: state.explorerSidebarPaneId,
});
nextLayout = addMissingEntityTabs({
@@ -2486,14 +2506,6 @@ export function reconcileWorkspaceTabs(
explorerSidebarPaneId: state.explorerSidebarPaneId,
});
nextLayout = seedDraftForEmptyWorkspace({
layout: nextLayout,
snapshot,
activeAgentIds,
knownTerminalIds,
explorerSidebarPaneId: state.explorerSidebarPaneId,
});
if (reconciledFocusedTabId) {
nextLayout =
focusTabInLayout({
@@ -0,0 +1,114 @@
import { z } from "zod";
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({
kind: z.literal("draft"),
draftId: z.string(),
setup: WorkspaceDraftTabSetupStorageSchema.optional(),
}),
z.strictObject({ kind: z.literal("agent"), agentId: z.string() }),
z.strictObject({
kind: z.literal("provider_subagent"),
parentAgentId: z.string(),
subagentId: z.string(),
}),
z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }),
z.strictObject({ kind: z.literal("browser"), browserId: z.string() }),
z.strictObject({ kind: z.literal("changes_tree") }),
z.strictObject({ kind: z.literal("files") }),
z.strictObject({ kind: z.literal("pull_request") }),
z.strictObject({
kind: z.literal("file"),
path: z.string(),
lineStart: z.number().int().positive().optional(),
lineEnd: z.number().int().positive().optional(),
}),
z.strictObject({
kind: z.literal("working_diff"),
focusPath: z.string().optional(),
focusRequestId: z.number().optional(),
// COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them.
mode: z.enum(["uncommitted", "base"]).optional(),
baseRef: z.string().nullable().optional(),
ignoreWhitespace: z.boolean().optional(),
}),
z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }),
z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }),
z.discriminatedUnion("context", [
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("workspace"),
}),
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("agent"),
agentId: z.string(),
}),
]),
]);
const WorkspaceTabStorageSchema = z.strictObject({
tabId: z.string(),
target: WorkspaceTabTargetStorageSchema,
createdAt: z.number(),
state: z.json().optional(),
});
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("pane"),
pane: z.strictObject({
id: z.string(),
tabIds: z.array(z.string()),
focusedTabId: z.string().nullable(),
tabs: z.array(WorkspaceTabStorageSchema).optional(),
hidden: z.boolean().optional(),
}),
}),
z.strictObject({
kind: z.literal("group"),
group: z.strictObject({
id: z.string(),
direction: z.enum(["horizontal", "vertical"]),
children: z.array(SplitNodeStorageSchema),
sizes: z.array(z.number()),
}),
}),
]),
);
const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject({
root: SplitNodeStorageSchema,
focusedPaneId: z.string().nullable(),
parentTabIdByTabId: z.record(z.string(), z.string()).optional(),
});
export const WorkspaceLayoutPersistedStateSchema = z.strictObject({
pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarWidth): added in v0.6, remove after 2027-08-25.
explorerSidebarRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarNaming): accepted from builds that called this dock the Side panel.
sidePanelRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
// rename here would fail every existing blob and wipe the layout it describes.
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
});
@@ -1085,6 +1085,118 @@ describe("workspace-layout-store actions", () => {
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before);
});
it.each(["hideExplorerSidebar", "showExplorerSidebar"] as const)(
"retains the last ordinary split after %s",
(setExplorerVisibility) => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const tabId = store.openTab({
workspaceKey,
target: { kind: "draft", draftId: "draft-origin" },
intent: "new",
}) as string;
store[setExplorerVisibility](workspaceKey);
const splitPaneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
}) as string;
store.moveTabToPane(workspaceKey, tabId, splitPaneId);
expect(
findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main"),
).toBeNull();
store.closeTab(workspaceKey, tabId);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(
collectAllPanes(layout.root)
.filter((pane) => pane.id !== "explorer")
.map((pane) => pane.id),
).toEqual([splitPaneId]);
expect(findPaneById(layout.root, splitPaneId)?.tabIds).toHaveLength(1);
expect(layout.focusedPaneId).toBe(splitPaneId);
},
);
it.each(["explorer", "pane_generated_explorer"])(
"restores an ordinary pane beside saved hidden %s without losing tabs",
async (explorerId) => {
const workspaceKey = createWorkspaceKey();
const saved = createPane({
id: explorerId,
hidden: true,
tabIds: ["files", "draft_saved"],
targetsByTabId: { files: { kind: "files" } },
stateByTabId: { draft_saved: { savedContent: "preserve me" } },
});
await AsyncStorage.setItem(
"workspace-layout-state",
JSON.stringify({
version: 2,
state: {
layoutByWorkspace: { [workspaceKey]: { root: saved, focusedPaneId: null } },
explorerPaneIdByWorkspace: { [workspaceKey]: explorerId },
},
}),
);
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
const state = restored.getState();
const layout = state.layoutByWorkspace[workspaceKey];
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, explorerId)).toEqual(
saved.kind === "pane" ? saved.pane : null,
);
expect(state.explorerSidebarPaneIdByWorkspace[workspaceKey]).toBe(explorerId);
const snapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
standaloneTerminalIds: [],
};
state.reconcileTabs(workspaceKey, snapshot);
const afterFirstReconcile = restored.getState().layoutByWorkspace[workspaceKey];
state.reconcileTabs(workspaceKey, snapshot);
expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual(afterFirstReconcile);
expect(
collectAllTabs(afterFirstReconcile.root).filter((tab) => tab.target.kind === "draft"),
).toHaveLength(1);
expect(
collectAllTabs(afterFirstReconcile.root).find((tab) => tab.tabId === "draft_saved")?.state,
).toEqual({ savedContent: "preserve me" });
},
);
it("reveals a saved hidden ordinary pane without replacing its content", async () => {
const workspaceKey = createWorkspaceKey();
const saved = createPane({
id: "pane_saved_ordinary",
hidden: true,
tabIds: ["draft_saved"],
stateByTabId: { draft_saved: { savedContent: "preserve me" } },
});
await AsyncStorage.setItem(
"workspace-layout-state",
JSON.stringify({
version: 2,
state: {
layoutByWorkspace: { [workspaceKey]: { root: saved, focusedPaneId: null } },
},
}),
);
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
const layout = restored.getState().layoutByWorkspace[workspaceKey];
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["pane_saved_ordinary"]);
expect(layout.focusedPaneId).toBe("pane_saved_ordinary");
expect(findPaneById(layout.root, "pane_saved_ordinary")?.tabIds).toEqual(["draft_saved"]);
expect(collectAllTabs(layout.root).find((tab) => tab.tabId === "draft_saved")?.state).toEqual({
savedContent: "preserve me",
});
});
it("migrates legacy layouts to include the hidden registered explorer pane", async () => {
const legacyLayout = createDefaultLayout();
await AsyncStorage.setItem(
@@ -3081,6 +3193,13 @@ describe("workspace-layout-store actions", () => {
intent: "reveal",
});
store.closeTab(workspaceKey, tabId!);
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
standaloneTerminalIds: [],
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const mainTab = collectAllTabs(layout.root).find(
@@ -3370,95 +3489,10 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
});
it("reconcileTabs lands on a draft when the hydrated workspace is empty", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const mainTab = collectAllTabs(layout.root).find(
(tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main",
);
expect(mainTab?.target.kind).toBe("draft");
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
});
it("reconcileTabs stays stable when startup removes a duplicate agent kept in Explorer", () => {
it("reconcileTabs initializes the New launcher once when the hydrated workspace is empty", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.78, 0.22],
children: [
createPane({
id: "main",
tabIds: ["new", "draft-origin-agent"],
focusedTabId: "draft-origin-agent",
targetsByTabId: {
new: { kind: "new_tab" },
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
},
}),
createPane({
id: "explorer",
tabIds: ["files", "changes_tree", "agent_agent-1"],
focusedTabId: "agent_agent-1",
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
},
}),
],
},
},
focusedPaneId: "main",
},
},
explorerSidebarPaneIdByWorkspace: {
...state.explorerSidebarPaneIdByWorkspace,
[workspaceKey]: "explorer",
},
}));
store.reconcileTabs(workspaceKey, {
agentsHydrated: false,
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
"main",
);
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
...state.layoutByWorkspace[workspaceKey],
focusedPaneId: "explorer",
},
},
}));
const emptySnapshot = {
const snapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
@@ -3467,21 +3501,117 @@ describe("workspace-layout-store actions", () => {
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
};
store.reconcileTabs(workspaceKey, emptySnapshot);
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(afterThirdReconcile).toBe(afterSecondReconcile);
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === "main",
);
expect(mainTabs).toEqual([
expect.objectContaining({ target: expect.objectContaining({ kind: "draft" }) }),
store.reconcileTabs(workspaceKey, snapshot);
const initialized = workspaceLayoutStore.getState();
const layout = initialized.layoutByWorkspace[workspaceKey];
expect(collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
{ kind: "new_tab" },
{ kind: "files" },
{ kind: "changes_tree" },
]);
expect(layout.focusedPaneId).toBe("main");
store.reconcileTabs(workspaceKey, snapshot);
expect(workspaceLayoutStore.getState()).toBe(initialized);
});
it.each([
["main", "explorer"],
["pane_ordinary", "pane_generated_explorer"],
])(
"reconcileTabs stays stable when startup removes a duplicate from %s beside %s",
(ordinaryPaneId, explorerPaneId) => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.78, 0.22],
children: [
createPane({
id: ordinaryPaneId,
tabIds: ["draft-origin-agent"],
focusedTabId: "draft-origin-agent",
targetsByTabId: {
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
},
}),
createPane({
id: explorerPaneId,
tabIds: ["files", "changes_tree", "agent_agent-1"],
focusedTabId: "agent_agent-1",
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
},
}),
],
},
},
focusedPaneId: ordinaryPaneId,
},
},
explorerSidebarPaneIdByWorkspace: {
...state.explorerSidebarPaneIdByWorkspace,
[workspaceKey]: explorerPaneId,
},
}));
store.reconcileTabs(workspaceKey, {
agentsHydrated: false,
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
ordinaryPaneId,
);
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
...state.layoutByWorkspace[workspaceKey],
focusedPaneId: explorerPaneId,
},
},
}));
const emptySnapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
};
store.reconcileTabs(workspaceKey, emptySnapshot);
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(afterThirdReconcile).toBe(afterSecondReconcile);
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === ordinaryPaneId,
);
expect(mainTabs).toEqual([expect.objectContaining({ target: { kind: "new_tab" } })]);
},
);
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
+25 -132
View File
@@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { z } from "zod";
import type { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import {
@@ -36,7 +37,7 @@ import {
openTabInLayoutBackground,
replaceTabTargetInLayout,
revealTargetInLayout,
restoreEmptyPanesInLayout,
restoreWorkspaceLayout,
reconcileWorkspaceTabs,
removePaneFromTree,
removeTabFromTree,
@@ -174,118 +175,6 @@ interface WorkspaceFocusRestorationState {
// Preserve four user-created split levels beneath that bookkeeping node.
const MAX_TREE_DEPTH = 5;
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({
kind: z.literal("draft"),
draftId: z.string(),
setup: WorkspaceDraftTabSetupStorageSchema.optional(),
}),
z.strictObject({ kind: z.literal("agent"), agentId: z.string() }),
z.strictObject({
kind: z.literal("provider_subagent"),
parentAgentId: z.string(),
subagentId: z.string(),
}),
z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }),
z.strictObject({ kind: z.literal("browser"), browserId: z.string() }),
z.strictObject({ kind: z.literal("changes_tree") }),
z.strictObject({ kind: z.literal("files") }),
z.strictObject({ kind: z.literal("pull_request") }),
z.strictObject({
kind: z.literal("file"),
path: z.string(),
lineStart: z.number().int().positive().optional(),
lineEnd: z.number().int().positive().optional(),
}),
z.strictObject({
kind: z.literal("working_diff"),
focusPath: z.string().optional(),
focusRequestId: z.number().optional(),
// COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them.
mode: z.enum(["uncommitted", "base"]).optional(),
baseRef: z.string().nullable().optional(),
ignoreWhitespace: z.boolean().optional(),
}),
z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }),
z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }),
z.discriminatedUnion("context", [
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("workspace"),
}),
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("agent"),
agentId: z.string(),
}),
]),
]);
const WorkspaceTabStorageSchema = z.strictObject({
tabId: z.string(),
target: WorkspaceTabTargetStorageSchema,
createdAt: z.number(),
state: z.json().optional(),
});
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("pane"),
pane: z.strictObject({
id: z.string(),
tabIds: z.array(z.string()),
focusedTabId: z.string().nullable(),
tabs: z.array(WorkspaceTabStorageSchema).optional(),
hidden: z.boolean().optional(),
}),
}),
z.strictObject({
kind: z.literal("group"),
group: z.strictObject({
id: z.string(),
direction: z.enum(["horizontal", "vertical"]),
children: z.array(SplitNodeStorageSchema),
sizes: z.array(z.number()),
}),
}),
]),
);
const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject({
root: SplitNodeStorageSchema,
focusedPaneId: z.string().nullable(),
parentTabIdByTabId: z.record(z.string(), z.string()).optional(),
});
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarWidth): added in v0.6, remove after 2027-08-25.
explorerSidebarRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// COMPAT(explorerSidebarNaming): accepted from builds that called this dock the Side panel.
sidePanelRatioByWorkspace: z.record(z.string(), z.number()).optional(),
// The persisted keys keep their pre-rename spelling: the schema is strict, so a
// rename here would fail every existing blob and wipe the layout it describes.
explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(),
// COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
});
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
@@ -634,7 +523,7 @@ function ensurePersistedExplorerSidebarPane(input: {
paneId: split.paneId,
hidden: true,
});
const seededLayout = restoreEmptyPanesInLayout(
const seededLayout = restoreWorkspaceLayout(
stripEphemeralTabsFromLayout(hiddenLayout ?? split.layout),
split.paneId,
);
@@ -991,14 +880,10 @@ export function createWorkspaceLayoutStore(
},
};
}
const preserveEmptyPaneId =
closingPane?.id === "main" || closingPane?.id === explorerSidebarPaneId
? closingPane.id
: null;
const closedLayout = closeTabInLayout({
layout,
tabId: normalizedTabId,
preserveEmptyPaneId,
explorerSidebarPaneId,
});
const nextLayoutBeforeFocusNormalization =
closedLayout &&
@@ -1156,8 +1041,13 @@ export function createWorkspaceLayoutStore(
return null;
}
const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey);
const result = convertDraftToAgentInLayout({
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
layout,
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
layout,
get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
),
tabId: normalizedTabId,
agentId: normalizedAgentId,
});
@@ -1224,6 +1114,7 @@ export function createWorkspaceLayoutStore(
}
}
if (
state.layoutByWorkspace[normalizedWorkspaceKey] &&
nextLayout === rawLayout &&
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace
) {
@@ -1400,7 +1291,7 @@ export function createWorkspaceLayoutStore(
}
const restoredLayout =
normalizedToPaneId === explorerSidebarPaneId
? restoreEmptyPanesInLayout(nextLayout, explorerSidebarPaneId)
? restoreWorkspaceLayout(nextLayout, explorerSidebarPaneId)
: nextLayout;
const normalizedNextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
restoredLayout,
@@ -1813,21 +1704,23 @@ export function createWorkspaceLayoutStore(
for (const [workspaceKey, persistedLayout] of Object.entries(
result.data.layoutByWorkspace,
)) {
const strippedLayout = stripEphemeralTabsFromLayout(persistedLayout);
const restoredLayout = restoreWorkspaceLayout(
stripEphemeralTabsFromLayout(persistedLayout),
resolveExplorerSidebarPaneId(
persistedLayout,
explorerSidebarPaneIdByWorkspace[workspaceKey],
),
ids.createNodeId,
);
const explorerSidebar = ensurePersistedExplorerSidebarPane({
layout: strippedLayout,
layout: restoredLayout,
registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey],
ids,
});
if (!explorerSidebar) {
layoutByWorkspace[workspaceKey] = restoreEmptyPanesInLayout(strippedLayout);
continue;
layoutByWorkspace[workspaceKey] = explorerSidebar?.layout ?? restoredLayout;
if (explorerSidebar) {
explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId;
}
layoutByWorkspace[workspaceKey] = restoreEmptyPanesInLayout(
explorerSidebar.layout,
explorerSidebar.paneId,
);
explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId;
}
return {
...currentState,
+11 -17
View File
@@ -240,20 +240,6 @@ async function callBrowserTool(client, name, args = {}) {
return mcpPayload(await client.callTool({ name, args }), name);
}
async function callBrowserToolUntilReady(client, name, args = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await client.callTool({ name, args });
const payload = result.structuredContent;
if (payload?.ok === true) return payload.result;
if (payload?.ok !== false || payload.error?.retryable !== true) {
return mcpPayload(result, name);
}
await delay(100);
}
throw new Error(`${name} remained unavailable for ${timeoutMs}ms`);
}
async function waitForGuestSelector(client, browserId) {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
@@ -373,7 +359,8 @@ async function readPresentation(page, browserId) {
async function readViewport(client, browserId) {
const evaluated = await callBrowserTool(client, "browser_evaluate", {
browserId,
function: "() => ({ width: window.innerWidth, height: window.innerHeight })",
function:
"() => ({ width: window.innerWidth, height: window.innerHeight, scale: window.devicePixelRatio })",
});
return JSON.parse(evaluated.resultJson);
}
@@ -639,10 +626,11 @@ async function runRegression({
const deviceSizeMenuPainted = await selectDeviceSize(page, "iPhone SE · 375×667");
assert(deviceSizeMenuPainted, "Device size menu did not paint above the browser surface");
const deviceViewport = await readViewport(client, browserId);
recordViewportMismatch(
failures,
"device size menu paints and receives input above the browser surface",
await readViewport(client, browserId),
deviceViewport,
{ width: 375, height: 667 },
);
@@ -651,6 +639,12 @@ async function runRegression({
text: "Bridge target",
timeoutMs: 5_000,
});
const resizedScreenshot = await callBrowserTool(client, "browser_screenshot", { browserId });
assert(
resizedScreenshot.width === Math.round(375 * deviceViewport.scale) &&
resizedScreenshot.height === Math.round(667 * deviceViewport.scale),
`Screenshot after resize returned ${resizedScreenshot.width}×${resizedScreenshot.height}`,
);
const requestedViewport = { width: 640, height: 480 };
await callBrowserTool(client, "browser_resize", { browserId, ...requestedViewport });
recordViewportMismatch(
@@ -726,7 +720,7 @@ async function runRegression({
{ timeout: timeoutMs },
);
try {
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
await callBrowserTool(client, "browser_screenshot", { browserId });
} catch (error) {
failures.push(`inactive browser remains captureable: ${String(error)}`);
}
@@ -781,6 +781,7 @@ async function openSmokeWorkspace({ appPath, env, page, daemonHome }) {
});
await page.getByRole("button", { name: "Sandbox smoke workspace", exact: true }).click();
await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`));
await page.getByRole("button", { name: "Agent", exact: true }).click();
await page.getByRole("textbox", { name: "Message agent..." }).click();
console.log(`Packaged desktop smoke: renderer opened workspace ${workspace.workspaceId}`);
}
@@ -1,6 +1,5 @@
import type { Rectangle } from "electron";
import { describe, expect, test, vi } from "vitest";
import type { TabImage } from "./service.js";
import { executeAutomationCommand, type BrowserRegistry, type TabImage } from "./service.js";
import { adaptWebContents, HostSnapshotEngineRegistry } from "./ipc.js";
import type { IsolatedKeyboardInputEvent } from "./trusted-input.js";
@@ -119,13 +118,11 @@ class FakeWebContents {
public readonly debugger = new FakeDebugger();
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
public readonly loadedUrls: string[] = [];
public readonly captures: Array<{
rect: Rectangle | undefined;
options: { stayHidden?: boolean } | undefined;
}> = [];
public frameListener: ((image: TabImage) => void) | null = null;
public endedFrameSubscriptions = 0;
public readonly invalidations: string[] = [];
private consoleMessageListener: ConsoleMessageListener | null = null;
private destroyedListener: (() => void) | null = null;
public readonly destroyedListeners = new Set<() => void>();
public destroyed = false;
public constructor(private readonly webContentsId: number) {}
@@ -175,12 +172,14 @@ class FakeWebContents {
public reload(): void {}
public async capturePage(
rect?: Rectangle,
options?: { stayHidden?: boolean },
): Promise<TabImage> {
this.captures.push({ rect, options });
return new FakeImage();
public beginFrameSubscription(onlyDirty: boolean, callback: (image: TabImage) => void): void {
expect(onlyDirty).toBe(false);
this.frameListener = callback;
}
public endFrameSubscription(): void {
this.frameListener = null;
this.endedFrameSubscriptions += 1;
}
public invalidate(): void {
@@ -198,7 +197,12 @@ class FakeWebContents {
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
this.destroyedListeners.add(listener);
}
public removeListener(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListeners.delete(listener);
}
public emitConsoleMessage(input: {
@@ -216,7 +220,11 @@ class FakeWebContents {
public destroy(): void {
this.destroyed = true;
this.debugger.emitDetach();
this.destroyedListener?.();
this.frameListener = null;
for (const listener of this.destroyedListeners) {
this.destroyedListeners.delete(listener);
listener();
}
}
}
@@ -245,16 +253,86 @@ describe("browser automation IPC adapter", () => {
]);
});
test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
test("captures one rendered frame and releases the subscription", async () => {
const contents = new FakeWebContents(20);
const tab = adaptWebContents(contents);
const controller = new AbortController();
const capture = tab.captureFrame(controller.signal);
contents.frameListener?.(new FakeImage());
const image = await tab.capturePage({ stayHidden: false });
tab.invalidate();
expect((await capture).getSize()).toEqual({ width: 640, height: 480 });
expect(contents.frameListener).toBeNull();
controller.abort();
expect(contents.endedFrameSubscriptions).toBe(1);
});
expect(image.getSize()).toEqual({ width: 640, height: 480 });
expect(contents.captures).toEqual([{ rect: undefined, options: { stayHidden: false } }]);
expect(contents.invalidations).toEqual(["invalidate"]);
test("cancels a pending frame subscription when the capture budget expires", async () => {
const contents = new FakeWebContents(2001);
const tab = adaptWebContents(contents);
const controller = new AbortController();
const capture = tab.captureFrame(controller.signal);
const failure = expect(capture).rejects.toThrow("capture deadline");
controller.abort(new Error("capture deadline"));
await failure;
expect(contents.frameListener).toBeNull();
expect(contents.endedFrameSubscriptions).toBe(1);
});
test("closing a guest settles its screenshot and releases the next queued capture", async () => {
vi.useFakeTimers();
try {
const closing = new FakeWebContents(2002);
const next = new FakeWebContents(2003);
const tabs = new Map([
["closing", adaptWebContents(closing)],
["next", adaptWebContents(next)],
]);
const registry: BrowserRegistry = {
listRegisteredBrowserIds: () => [...tabs.keys()],
listRegisteredBrowserIdsForWorkspace: () => [...tabs.keys()],
getTabContents: (id) => tabs.get(id) ?? null,
getBrowserWorkspaceId: () => null,
getWorkspaceActiveBrowserId: () => null,
};
const capture = (browserId: string) =>
executeAutomationCommand(
{
type: "browser.automation.execute.request",
requestId: browserId,
command: { command: "screenshot", args: { browserId, fullPage: false } },
},
registry,
);
let closedResult: unknown;
const closedCapture = Promise.resolve(capture("closing")).then((result) => {
closedResult = result;
return result;
});
const nextCapture = capture("next");
await vi.advanceTimersByTimeAsync(0);
expect(closing.frameListener).not.toBeNull();
closing.destroy();
await vi.advanceTimersByTimeAsync(0);
expect(closedResult).toEqual({
requestId: "closing",
ok: false,
error: {
code: "browser_tab_closed",
message: "Browser tab closing has been closed",
retryable: false,
},
});
expect(closing.destroyedListeners.size).toBe(0);
expect(next.frameListener).not.toBeNull();
next.frameListener?.(new FakeImage());
await expect(nextCapture).resolves.toMatchObject({ ok: true });
await closedCapture;
expect(next.destroyedListeners.size).toBe(1);
} finally {
await vi.runAllTimersAsync();
vi.useRealTimers();
}
});
test("collects console messages until the guest is destroyed", () => {
@@ -1,4 +1,3 @@
import type { Rectangle } from "electron";
import { ipcMain } from "electron";
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type {
@@ -16,7 +15,7 @@ import {
promptShimInstallScript,
promptShimRestoreScript,
} from "./dialog-handling.js";
import { executeAutomationCommand } from "./service.js";
import { BrowserTabClosedError, executeAutomationCommand } from "./service.js";
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
import {
listRegisteredPaseoBrowserIds,
@@ -92,6 +91,7 @@ interface ConsoleMessageEmitter {
}
interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
removeListener(event: "destroyed", listener: () => void): void;
readonly id: number;
readonly debugger: WebContentsDebugger;
getURL(): string;
@@ -105,7 +105,8 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
goBack(): void;
goForward(): void;
reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
beginFrameSubscription(onlyDirty: boolean, callback: (image: TabImage) => void): void;
endFrameSubscription(): void;
invalidate(): void;
getBackgroundThrottling(): boolean;
setBackgroundThrottling(allowed: boolean): void;
@@ -130,7 +131,7 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
goBack: () => contents.goBack(),
goForward: () => contents.goForward(),
reload: () => contents.reload(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
captureFrame: (signal) => captureViewportFrame(contents, signal),
invalidate: () => contents.invalidate(),
withFrameProduction: async (capture) => {
const previous = contents.getBackgroundThrottling();
@@ -154,6 +155,41 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
};
}
function captureViewportFrame(
contents: BrowserAutomationWebContents,
signal: AbortSignal,
): Promise<TabImage> {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const stop = () => {
signal.removeEventListener("abort", abort);
contents.removeListener("destroyed", destroyed);
if (!contents.isDestroyed()) contents.endFrameSubscription();
};
const abort = () => {
stop();
reject(signal.reason);
};
const destroyed = () => {
stop();
reject(new BrowserTabClosedError());
};
signal.addEventListener("abort", abort, { once: true });
contents.once("destroyed", destroyed);
try {
// A resized resident guest can paint while capturePage's surface-copy
// request remains pending. Subscribe to its rendered frames instead.
contents.beginFrameSubscription(false, (image) => {
stop();
resolve(image);
});
} catch (error) {
stop();
reject(error);
}
});
}
function getCdpQueue(contentsId: number): CdpSessionQueue {
const existing = cdpQueuesByContentsId.get(contentsId);
if (existing) {
@@ -39,7 +39,7 @@ class FakeTab implements TabContents {
public readonly loadedUrls: string[] = [];
public readonly scripts: string[] = [];
public readonly actions: string[] = [];
public readonly capturedViewports: Array<{ stayHidden?: boolean }> = [];
public readonly capturedViewports: AbortSignal[] = [];
public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = [];
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
private readonly captureStartWaiters: Array<() => void> = [];
@@ -71,7 +71,6 @@ class FakeTab implements TabContents {
public captureNeverPaints = false;
public captureThrows = false;
public captureErrorMessage = "capture failed";
public viewportCaptureFailuresBeforeSuccess = 0;
public deferCaptures = false;
public fullPageScreenshotThrows = false;
public fullPageScreenshotErrorMessage = "UnknownVizError";
@@ -168,14 +167,10 @@ class FakeTab implements TabContents {
this.actions.push("reload");
}
public async capturePage(options?: { stayHidden?: boolean }): Promise<TabImage> {
this.capturedViewports.push(options ?? {});
public async captureFrame(signal: AbortSignal): Promise<TabImage> {
this.capturedViewports.push(signal);
this.actions.push("capture");
this.resolveCaptureStartWaiters();
if (this.viewportCaptureFailuresBeforeSuccess > 0) {
this.viewportCaptureFailuresBeforeSuccess -= 1;
throw new Error(this.captureErrorMessage);
}
if (this.captureThrows) {
throw new Error(this.captureErrorMessage);
}
@@ -1564,7 +1559,8 @@ describe("executeAutomationCommand", () => {
height: 480,
},
});
expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]);
expect(browser.tab.capturedViewports).toHaveLength(1);
expect(browser.tab.capturedViewports[0].aborted).toBe(true);
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
});
@@ -1590,6 +1586,7 @@ describe("executeAutomationCommand", () => {
},
});
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
expect(browser.tab.capturedViewports[0].aborted).toBe(true);
} finally {
vi.useRealTimers();
}
@@ -1634,76 +1631,6 @@ describe("executeAutomationCommand", () => {
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
});
test("screenshot retries UnknownVizError until the first viewport frame appears", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.captureErrorMessage = "UnknownVizError";
browser.tab.viewportCaptureFailuresBeforeSuccess = 2;
const resultPromise = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await vi.advanceTimersByTimeAsync(400);
await expect(resultPromise).resolves.toEqual({
requestId: "req-screenshot",
ok: true,
result: {
command: "screenshot",
browserId: BROWSER_A,
mimeType: "image/png",
dataBase64: "iVBORwECAw==",
width: 640,
height: 480,
},
});
expect(browser.tab.capturedViewports).toHaveLength(3);
expect(browser.tab.actions).toEqual([
"invalidate",
"capture",
"invalidate",
"capture",
"invalidate",
"capture",
]);
} finally {
vi.useRealTimers();
}
});
test("screenshot returns no-frame after UnknownVizError spends the retry budget", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true;
browser.tab.captureErrorMessage = "UnknownVizError";
const resultPromise = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(resultPromise).resolves.toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message: "The tab has not painted yet. Retry the screenshot.",
retryable: true,
},
});
expect(browser.tab.capturedViewports.length).toBeGreaterThan(1);
expect(browser.tab.actions.filter((action) => action === "invalidate")).toHaveLength(
browser.tab.capturedViewports.length,
);
} finally {
vi.useRealTimers();
}
});
test("overlapping screenshots serialize through the shared capture queue", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.deferCaptures = true;
@@ -35,7 +35,7 @@ export interface TabContents {
goBack(): void;
goForward(): void;
reload(): void;
capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
captureFrame(signal: AbortSignal): Promise<TabImage>;
invalidate(): void;
withFrameProduction<T>(capture: () => Promise<T>): Promise<T>;
sendInputEvent(event: IsolatedKeyboardInputEvent): void;
@@ -51,10 +51,6 @@ export interface TabImage {
getSize(): { width: number; height: number };
}
export interface TabCapturePageOptions {
stayHidden?: boolean;
}
export interface BrowserRegistry {
listRegisteredBrowserIds(): string[];
listRegisteredBrowserIdsForWorkspace(workspaceId: string): string[];
@@ -98,6 +94,13 @@ async function withDialogCapture(
return dialogs.length > 0 ? { ...result, dialogs } : result;
}
export class BrowserTabClosedError extends Error {
public constructor() {
super("Browser tab has been closed");
this.name = "BrowserTabClosedError";
}
}
class ScreenshotNoFrameError extends Error {
public constructor(message = SCREENSHOT_NO_FRAME_MESSAGE) {
super(message);
@@ -215,7 +218,22 @@ async function runPaintedPixelCapture<T>(
}
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
return runPaintedPixelCapture(contents, () => contents.capturePage({ stayHidden: false }));
return runSerializedPixelCapture(() =>
contents.withFrameProduction(async () => {
const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS;
const controller = new AbortController();
try {
await waitForPaint(contents, deadline);
contents.invalidate();
return await withPixelCaptureTimeout(
contents.captureFrame(controller.signal),
deadline - Date.now(),
);
} finally {
controller.abort();
}
}),
);
}
function tabInfoFromContents(
@@ -1217,6 +1235,9 @@ async function executeScreenshot(
try {
image = await capturePaintedViewport(target.contents);
} catch (error) {
if (error instanceof BrowserTabClosedError) {
return fail(requestId, "browser_tab_closed", `Browser tab ${browserId} has been closed`);
}
if (isScreenshotNoFrameError(error)) {
return screenshotNoFrameFailure(requestId, error);
}