Make the workspace right pane a first-class tab host (#3287)
* feat(app): add persistent workspace side panes Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry. * fix(app): localize pull request empty state * test(app): update explorer pane coverage * fix(app): preserve explorer pane workflows * test(app): make explorer tab selection idempotent * fix(app): persist workspace review pins * fix(app): preserve sidebar scrim drag hook * fix(app): restore companion pane interactions * test(app): follow selected sidebar surface
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
|
||||
|
||||
// The branch switcher lives in the git diff panel's Changes header (right-side
|
||||
// ExplorerSidebar), not in the workspace header. It renders as a button whose
|
||||
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
||||
// accessible name carries the current branch ("Current branch: <name>. Press to
|
||||
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
||||
// what a screen reader user hears, and it proves the panel resolved a real
|
||||
@@ -16,17 +16,9 @@ function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
.first();
|
||||
}
|
||||
|
||||
// Opens the right-side explorer and lands on the Changes tab, where the branch
|
||||
// switcher and diff live. Git checkouts default to the Changes tab when the
|
||||
// explorer opens, so this is enough to reveal the switcher on desktop and mobile.
|
||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first();
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
await openWorkspaceChangesPanel(page);
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
return page.getByTestId("file-explorer-tree-scroll");
|
||||
@@ -9,8 +10,7 @@ function fileExplorerEntry(page: Page, name: string) {
|
||||
}
|
||||
|
||||
export async function openFileExplorer(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).first().click();
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await openFilesPanel(page);
|
||||
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createAgentTabFromMenu } from "./workspace-tabs";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,10 +70,10 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** Assert the inline new-agent plus button is visible in the tab bar. */
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -85,14 +86,9 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void>
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Click the inline plus button to create a draft/chat tab. */
|
||||
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page
|
||||
.getByTestId("workspace-new-agent-tab-inline")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
await createAgentTabFromMenu(page);
|
||||
}
|
||||
|
||||
/** Open the new-tab menu and click "New terminal". */
|
||||
@@ -129,9 +125,9 @@ export async function waitForTabWithTitle(
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the inline new-agent plus button is visible in the tab bar. */
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true });
|
||||
const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-pr").click();
|
||||
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
|
||||
await openPullRequestPanel(page);
|
||||
}
|
||||
|
||||
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export interface SplitPaneGeometry {
|
||||
containerWidth: number;
|
||||
explorerWidth: number;
|
||||
visibleWidth: number;
|
||||
separatorCount: number;
|
||||
occupiedWidth: number;
|
||||
}
|
||||
|
||||
function splitSeparator(page: Page): Locator {
|
||||
return page.getByRole("separator", { name: "" });
|
||||
}
|
||||
|
||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close explorer" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
|
||||
const separator = splitSeparator(page);
|
||||
await expect(separator).toHaveCount(1);
|
||||
const box = await separator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) throw new Error("Split separator has no bounding box");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function stampExplorerNode(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByTestId("split-group-child")
|
||||
.last()
|
||||
.evaluate((node) => {
|
||||
Reflect.set(window, "__splitPaneExplorerNode", node);
|
||||
node.setAttribute("data-split-pane-identity", "retained");
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
|
||||
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
|
||||
await expect(stampedNode).toHaveCount(1);
|
||||
expect(
|
||||
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
|
||||
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
|
||||
await expect(explorerChild).toHaveCount(1);
|
||||
|
||||
return explorerChild.evaluate((explorerNode) => {
|
||||
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate !== explorerNode &&
|
||||
(candidate as HTMLElement).dataset.testid === "split-group-child",
|
||||
)
|
||||
.sort(
|
||||
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
|
||||
)[0];
|
||||
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
|
||||
const visible = visibleNode.getBoundingClientRect();
|
||||
const explorer = explorerNode.getBoundingClientRect();
|
||||
const container = explorerNode.parentElement?.getBoundingClientRect();
|
||||
if (!container) throw new Error("Split child has no container");
|
||||
const separatorCount =
|
||||
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
|
||||
return {
|
||||
containerWidth: container.width,
|
||||
explorerWidth: explorer.width,
|
||||
visibleWidth: visible.width,
|
||||
separatorCount,
|
||||
occupiedWidth: explorer.width + visible.width,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||
@@ -17,15 +17,89 @@ function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function explorerPaneTabRow(page: Page) {
|
||||
return visibleTestId(page, "workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
if ((await tab.getAttribute("aria-selected")) !== "true") {
|
||||
// The close action overlays the chip's trailing edge on hover. Click the
|
||||
// leading icon area so Playwright does not target that separate control.
|
||||
await tab.click({ position: { x: 12, y: 13 } });
|
||||
}
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function ensureExplorerPane(page: Page) {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const tabRow = explorerPaneTabRow(page);
|
||||
if ((await tabRow.count()) === 0) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(tabRow).toBeVisible({ timeout: 30_000 });
|
||||
return tabRow;
|
||||
}
|
||||
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const filesTab = tabRow.getByTestId("workspace-tab-files");
|
||||
if ((await filesTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(filesTab);
|
||||
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
const existingTab = visibleTestId(page, "workspace-tab-pull_request").first();
|
||||
if ((await existingTab.count()) > 0) {
|
||||
await selectWorkspaceTab(existingTab);
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
|
||||
if ((await pullRequestTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(pullRequestTab);
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visibleTestId(page, "workspace-new-agent-tab-inline").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the `+` menu in the tab row and pick "New agent". */
|
||||
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||
await expect(item).toBeVisible({ timeout: 10_000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
|
||||
Reference in New Issue
Block a user