Give every pane a browser-style New tab (#3715)

* feat(app): replace empty panes with New tabs

Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage.

* test(app): count content tabs in subagent integration

* fix(app): preserve workspace shortcut ownership
This commit is contained in:
Mohamed Boudra
2026-08-22 21:30:15 +02:00
committed by GitHub
parent 33cf6cf337
commit c60fa098ac
48 changed files with 1663 additions and 1294 deletions
@@ -84,6 +84,25 @@ test("composer diff stat toggles Changes in the preferred Side panel", async ({
}
});
test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-compact-");
try {
await page.setViewportSize({ width: 390, height: 844 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.getByTestId("composer-diff-stat-pill").click();
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
page,
}) => {
@@ -5,12 +5,12 @@ import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
function visibleEmptyPane(page: Page): Locator {
return page.getByTestId("workspace-pane-empty-state").filter({ visible: true });
function visibleNewTabPanel(page: Page): Locator {
return page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
}
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
await expect(launcher.getByRole("button", { name })).toBeFocused();
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
}
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
@@ -19,12 +19,12 @@ async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
const launcher = page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-pane-empty-state");
.getByTestId("workspace-new-tab-panel");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
}
test.describe("Empty pane keyboard launcher", () => {
test.describe("New tab keyboard launcher", () => {
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
@@ -58,15 +58,12 @@ test.describe("Empty pane keyboard launcher", () => {
await waitForWorkspaceTabsVisible(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleEmptyPane(page);
const launcher = visibleNewTabPanel(page);
await expect(launcher).toBeVisible({ timeout: 30_000 });
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowUp");
await expectLauncherSelection(launcher, "Terminal");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
@@ -2,9 +2,8 @@
// 1) with the explorer pane focused, a newly appearing agent must auto-open into the
// MAIN pane in the background — the explorer pane is a background surface and must
// never swallow entity tabs or lose the user's focus, and
// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay
// interactive in the [agent | empty main | explorer] state (the reporter hit an
// app-wide lockup here with no way to close the empty pane).
// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay
// interactive in the [agent | New | explorer] state.
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
@@ -53,19 +52,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
}
function emptyPaneChild(page: Page): Locator {
function newTabPaneChild(page: Page): Locator {
return page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
.filter({ has: page.getByTestId("workspace-new-tab-panel") })
.first();
}
async function emptyPaneBox(page: Page) {
const paneChild = emptyPaneChild(page);
const paneChild = newTabPaneChild(page);
await expect(paneChild).toBeVisible({ timeout: 15_000 });
const box = await paneChild.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Empty pane has no bounding box");
if (!box) throw new Error("New tab pane has no bounding box");
return box;
}
@@ -97,7 +96,7 @@ async function dragChipTo(
}
test.describe("explorer pane tab placement", () => {
test("agents open in the main pane and empty-pane splits keep the app usable", async ({
test("agents open in the main pane and New-tab splits keep the app usable", async ({
page,
}, testInfo) => {
const consoleErrors: string[] = [];
@@ -171,9 +170,9 @@ test.describe("explorer pane tab placement", () => {
body: await page.screenshot(),
contentType: "image/png",
});
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
await test.step("app must stay interactive after the split", async () => {
@@ -186,17 +185,12 @@ test.describe("explorer pane tab placement", () => {
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
// 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 = emptyPaneChild(page);
const emptyPane = newTabPaneChild(page);
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
// The empty pane's + menu must open and dismiss cleanly.
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
await emptyPaneTrigger.click({ timeout: 5_000 });
await page.keyboard.press("Escape");
// A second drag must work: center-drop the agent tab back into the empty pane.
// 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,
@@ -207,8 +201,8 @@ test.describe("explorer pane tab placement", () => {
contentType: "image/png",
});
// The agent chip must have moved into the previously empty pane and stay clickable.
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
// 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)
@@ -71,7 +71,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(dockedSidebar(page)).toHaveCount(0);
await toggle.click();
@@ -108,7 +108,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
@@ -122,7 +122,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
});
} finally {
await workspace.cleanup();
@@ -494,10 +494,7 @@ test.describe("CodeMirror workspace file editing", () => {
.filter({ visible: true })
.first()
.click({ button: "right" });
await page
.getByTestId("workspace-tab-context-file_draft.ts-close")
.filter({ visible: true })
.click();
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
expect(closePrompt).toContain("Closing it will discard the draft.");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
+86 -56
View File
@@ -1,11 +1,11 @@
import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
pressDirectNewTabShortcut,
clickNewChat,
clickNewTerminal,
countTabsOfKind,
@@ -45,6 +45,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
};
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
return tabs.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
}
function tabIdentityKey(snapshot: Array<{ id: string }>): string {
return JSON.stringify(snapshot.map(({ id }) => id));
}
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
const created = await workspace.client.createWorkspace({
@@ -66,14 +76,12 @@ test.afterAll(async () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => {
test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => {
if (!secondWorkspaceId) {
throw new Error("Secondary workspace was not created");
}
const serverId = getServerId();
const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ });
const switchWorkspaceRow = async (workspaceId: string) => {
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -92,11 +100,12 @@ test.describe("Tab creation", () => {
for (let i = 0; i < 8; i++) {
await switchWorkspaceRow(sequence[i % sequence.length]);
await pressNewTabShortcut(page);
await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
`New tab did not open after switch ${i}`,
).toBeVisible({
timeout: 5_000,
});
await page.keyboard.press("Escape");
await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 });
}
});
@@ -104,72 +113,75 @@ test.describe("Tab creation", () => {
await gotoWorkspace(page, workspace.workspaceId);
const workspaceUrl = page.url();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
const newTabCountBefore = await countTabsOfKind(page, "new_tab");
await page.keyboard.press(`${modifier}+Comma`);
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible();
await page.keyboard.press(`${modifier}+t`);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await page.goto(workspaceUrl);
await assertNewTabMenuTriggerVisible(page);
const countBefore = await countTabsOfKind(page, "draft");
await pressDirectNewTabShortcut(page, "a");
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1);
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
});
test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => {
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await openNewTabMenuWithShortcut(page);
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible();
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
const countBefore = await newTabs.count();
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 1);
const firstIds = await tabTestIds(newTabs);
await pressDirectNewTabShortcut(page, "a");
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1);
await pressNewTabShortcut(page);
await expect(newTabs).toHaveCount(countBefore + 2);
const ids = await tabTestIds(newTabs);
expect(new Set(ids).size).toBe(ids.length);
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
});
test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => {
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openNewTabMenuWithShortcut(page);
const agent = page.getByRole("menuitem", { name: /^Agent/ });
const terminal = page.getByRole("menuitem", { name: /^Terminal/ });
const changes = page.getByRole("menuitem", { name: /Changes/ });
const files = page.getByRole("menuitem", { name: /Files/ });
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
const agent = panel.getByRole("button", { name: /^Agent/ });
const terminal = panel.getByRole("button", { name: /^Terminal/ });
const changes = panel.getByRole("button", { name: /Changes/ });
const files = panel.getByRole("button", { name: /Files/ });
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
await expect(agent).toBeFocused();
await expect(changes).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(terminal).toBeFocused();
await expect(files).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(agent).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "New tab" })).toBeFocused();
await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute(
"aria-expanded",
"false",
);
await expect(changes).toBeFocused();
await page.locator("body").click({ position: { x: 1, y: 1 } });
await panel.click({ position: { x: 20, y: 20 } });
await expect(changes).toBeFocused();
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
@@ -205,8 +217,8 @@ test.describe("Tab creation", () => {
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByRole("button", { name: EMPTY_PROMPT_PROFILE.name }).click();
await expectTerminalOutputContains(page, "prompt-args: 0");
} finally {
@@ -214,6 +226,20 @@ test.describe("Tab creation", () => {
}
});
test("terminal profiles are grouped with a settings action", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
await expect(panel.getByText("Terminal profiles", { exact: true })).toBeVisible();
const editProfiles = panel.getByTestId("workspace-new-tab-edit-terminal-profiles");
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
await editProfiles.click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
@@ -226,15 +252,13 @@ test.describe("Tab creation", () => {
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
const newTabButtonInScroll = await scrollArea
.getByTestId("workspace-new-tab-menu-trigger")
.count();
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
const scrollShades = await tabRow
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
.count();
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible();
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
});
});
@@ -333,28 +357,34 @@ test.describe("Terminal title propagation", () => {
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
test("New agent tab transition has no blank intermediate tab state", async ({
page,
withWorkspace,
}) => {
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
await isolatedWorkspace.navigateTo();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await expect(
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page));
// Sample the single New → Agent replacement, not the separate action that
// creates the New tab in the first place.
const snapshots = await sampleTabsDuringTransition(page, async () => {
await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click();
});
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
// When running in-suite, previous tests may have created tabs on the shared workspace,
// so we allow +2 tolerance for accumulated state and React render batching.
// Replacement is atomic: the set of identities changes once, while its size stays fixed.
const counts = snapshots.map((snapshot) => snapshot.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual(
2,
);
expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
});
@@ -0,0 +1,49 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
test("a workspace with an existing agent never presents New during startup", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Existing startup agent",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "ready",
});
try {
await page.addInitScript(() => {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", false);
function recordVisibleNewTab() {
const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]');
if (panel?.getClientRects().length) {
Reflect.set(globalThis, "__paseoSawVisibleNewTab", true);
}
}
window.addEventListener("DOMContentLoaded", () => {
new MutationObserver(recordVisibleNewTab).observe(document.documentElement, {
childList: true,
subtree: true,
});
recordVisibleNewTab();
});
});
await gotoWorkspace(page, workspace.workspaceId);
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount(
0,
);
expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe(
false,
);
} finally {
await workspace.cleanup();
}
});
+36 -33
View File
@@ -18,8 +18,8 @@ function mainPane(page: Page): Locator {
return page.getByTestId("workspace-pane-main").filter({ visible: true });
}
function emptyState(scope: Locator): Locator {
return scope.getByTestId("workspace-pane-empty-state");
function newTabPanel(scope: Locator): Locator {
return scope.getByTestId("workspace-new-tab-panel");
}
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
@@ -32,10 +32,6 @@ function sidePanelToggle(page: Page): Locator {
return page.getByTestId("workspace-explorer-toggle").first();
}
function closePaneButton(page: Page): Locator {
return page.getByRole("button", { name: "Close pane" });
}
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
const paneBox = await mainPane(page).boundingBox();
@@ -67,21 +63,20 @@ test.describe("Side panel", () => {
await test.step("revealing the Side panel seeds nothing", async () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
const launcher = emptyState(sidePanel(page));
const launcher = newTabPanel(sidePanel(page));
await expect(launcher).toBeVisible();
expect(await launcherButtonLabels(launcher)).toEqual([
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
"Changes",
"Files",
"Agent",
"Terminal",
null,
]);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
await capture(page, testInfo, "01-side-panel-reveals-empty");
});
await test.step("Changes opens in the Side panel that offered it", async () => {
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
@@ -91,10 +86,6 @@ test.describe("Side panel", () => {
await capture(page, testInfo, "02-changes-opens-in-side-panel");
});
await test.step("a loaded pane offers no one-tap close", async () => {
await expect(closePaneButton(page)).toHaveCount(0);
});
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
await changesTab.hover();
@@ -107,7 +98,7 @@ test.describe("Side panel", () => {
await sidePanelToggle(page).click();
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
await expect(emptyState(sidePanel(page))).toBeVisible();
await expect(newTabPanel(sidePanel(page))).toBeVisible();
await capture(page, testInfo, "04-side-panel-revealed-empty");
});
} finally {
@@ -137,23 +128,30 @@ test.describe("Side panel", () => {
.first();
await expect(draftPane).toBeVisible({ timeout: 15_000 });
const emptyMain = emptyState(mainPane(page));
const emptyMain = newTabPanel(mainPane(page));
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, "06-empty-main-pane-launcher");
await emptyMain.getByRole("button", { name: "Close pane" }).click();
const newTabChip = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await newTabChip.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
await expect(sidePanel(page)).toBeVisible();
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
await capture(page, testInfo, "07-empty-main-pane-removed");
} finally {
await workspace.cleanup();
}
});
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
try {
@@ -164,7 +162,9 @@ test.describe("Side panel", () => {
// holds that tab, so emptying Main does not look like an empty workspace and
// nothing reseeds a draft into it.
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
await newTabPanel(sidePanel(page))
.getByRole("button", { name: "Files", exact: true })
.click();
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
timeout: 30_000,
});
@@ -182,13 +182,20 @@ test.describe("Side panel", () => {
.first()
.click();
// Main is now empty and the only pane the user can see. Taking it away would
// leave nowhere to look, so the launcher appears without a Close pane.
const lastPane = emptyState(mainPane(page));
// Main now contains New and is the only pane the user can see. Closing its
// tab cannot remove the final visible pane.
const lastPane = newTabPanel(mainPane(page));
await expect(lastPane).toBeVisible({ timeout: 15_000 });
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
await expect(closePaneButton(page)).toHaveCount(0);
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
const finalNewTab = mainPane(page)
.locator('[data-testid^="workspace-tab-tab_"]')
.filter({ hasText: "New tab" });
await finalNewTab.hover();
await mainPane(page)
.locator('[data-testid^="workspace-new-tab-close-"]')
.filter({ visible: true })
.click();
await expect(lastPane).toBeVisible();
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
} finally {
await workspace.cleanup();
@@ -204,18 +211,14 @@ test.describe("Side panel", () => {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await ensureSidePanel(page);
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "08-changes-in-side-panel");
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
await page
.getByTestId("workspace-new-tab-menu-changes")
.filter({ visible: true })
.first()
.click();
await mainPane(page).getByTestId("workspace-new-tab-button").click();
await page.getByTestId("workspace-new-tab-changes").filter({ visible: true }).first().click();
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
timeout: 15_000,
@@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness
beforeXterm.rows,
);
// Split the pane downward. This focuses the new empty pane, so the terminal
// Split the pane downward. This focuses the new tab pane, so the terminal
// pane is unfocused at the exact moment its container shrinks.
await runWorkspaceActionFromCommandCenter(page, "Split pane down");
@@ -59,8 +59,8 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
// Workspaces always render a hidden explorer companion pane alongside the
// main pane, so an unscoped testid locator matches both; scope to the
// visible one.
await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-menu-terminal").click();
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
await page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first().click();
}
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
@@ -128,7 +128,7 @@ test.describe("terminal PTY size claim under lost window focus", () => {
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }),
).toBeVisible({
timeout: 30_000,
});
@@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => {
.toBe(promptTitle);
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
await agentTab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Escape");
@@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => {
.first()
.elementHandle();
expect(originalComposer).not.toBeNull();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
emptyPanesBeforeSplit + 1,
{ timeout: 30_000 },
);
@@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const copyTerminalId = page.getByTestId(
`workspace-tab-context-terminal_${terminalId}-copy-terminal-id`,
);
const copyTerminalId = page.getByRole("menuitem", {
name: /^Copy terminal id/,
});
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click();
@@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => {
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
+11 -17
View File
@@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
@@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
export async function openNewTabMenuWithShortcut(page: Page): Promise<void> {
await pressNewTabShortcut(page);
await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible();
}
export async function pressDirectNewTabShortcut(page: Page, key: string): Promise<void> {
@@ -83,36 +83,30 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
/** 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-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
/** Assert the New tab button is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */
/** Create a New tab and choose Agent to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> {
await createAgentTabFromMenu(page);
}
/** Open the new-tab menu and click "New terminal". */
/** Create a New tab and choose Terminal. */
export async function clickNewTerminal(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = page
.getByTestId("workspace-new-tab-menu-terminal")
.filter({ visible: true })
.first();
const item = page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
@@ -137,7 +131,7 @@ export async function waitForTabWithTitle(
/** 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-tab-menu-trigger").filter({ visible: true });
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
@@ -224,7 +218,7 @@ export async function expectTabTitleFits(
}
export function terminalSurfaceLocator(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first();
}
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
@@ -88,13 +88,16 @@ export async function ensureSidePanel(page: Page): Promise<Locator> {
*/
async function openSidePanelView(
page: Page,
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
view: { tabTestId: string; launcherTestId: string; contentTestId: string; timeout?: number },
): Promise<void> {
const panel = await ensureSidePanel(page);
const tab = panel.getByTestId(view.tabTestId);
if ((await tab.count()) === 0) {
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
await visibleTestId(page, view.menuTestId).first().click();
const launcher = panel.getByTestId("workspace-new-tab-panel");
if ((await launcher.count()) === 0) {
await panel.getByTestId("workspace-new-tab-button").click();
}
await panel.getByTestId(view.launcherTestId).click();
}
await selectWorkspaceTab(tab);
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
@@ -105,7 +108,7 @@ async function openSidePanelView(
export async function openChangesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-working_diff",
menuTestId: "workspace-new-tab-menu-changes",
launcherTestId: "workspace-new-tab-changes",
contentTestId: "working-diff-panel",
});
}
@@ -113,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise<void> {
export async function openFilesPanel(page: Page): Promise<void> {
await openSidePanelView(page, {
tabTestId: "workspace-tab-files",
menuTestId: "workspace-new-tab-menu-files",
launcherTestId: "workspace-new-tab-files",
contentTestId: "file-explorer-tree-scroll",
});
}
@@ -127,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
}
await openSidePanelView(page, {
tabTestId: "workspace-tab-pull_request",
menuTestId: "workspace-new-tab-menu-pull-request",
launcherTestId: "workspace-new-tab-pull-request",
contentTestId: "pr-pane",
timeout: 15_000,
});
@@ -137,17 +140,17 @@ 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-tab-menu-trigger").first()).toBeVisible({
await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({
timeout: 30_000,
});
}
/** Open the `+` menu in the tab row and pick "New agent". */
/** Create a New tab from `+` and pick Agent in its launcher. */
export async function createAgentTabFromMenu(page: Page): Promise<void> {
const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first();
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
const item = visibleTestId(page, "workspace-new-tab-agent").first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
}
+26 -90
View File
@@ -69,7 +69,6 @@ import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -110,10 +109,7 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
onCreateNewTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
@@ -129,15 +125,8 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onClosePane: (paneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
@@ -183,7 +172,6 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
paneIsolated: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
@@ -403,19 +391,14 @@ export function SplitContainer({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
focusModeEnabled,
onExitFocusMode,
}: SplitContainerProps) {
@@ -503,12 +486,6 @@ export function SplitContainer({
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
// Isolating a pane (focus mode, maximize) hides its siblings without removing
// them, so the one pane on screen is not really the last one and closing it is not
// what anyone means. That is all this container knows; whether the layout itself
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
if (!data) {
@@ -688,19 +665,13 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
@@ -899,19 +870,13 @@ function SplitNodeView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
paneIsolated,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
@@ -980,18 +945,12 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
@@ -1034,19 +993,13 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={showDropZones}
dropPreview={dropPreview}
@@ -1096,18 +1049,12 @@ function SplitPaneView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
onCreateNewTab,
buildPaneContentModel,
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
paneIsolated,
onClosePane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
@@ -1219,7 +1166,6 @@ function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View
@@ -1248,10 +1194,7 @@ function SplitPaneView({
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
externalDndContext
activeDragTabId={activeDragTabId}
@@ -1267,32 +1210,25 @@ function SplitPaneView({
</WindowChromeSafeArea>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
{mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
isFocused: isFocused && isWorkspaceFocused,
onClosePane: handleClosePane,
}) ?? null)}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
+1 -1
View File
@@ -70,7 +70,7 @@ const styles = StyleSheet.create((theme) => ({
badgeBackground: {
...StyleSheet.absoluteFillObject,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface4,
backgroundColor: theme.colors.surface3,
opacity: theme.opacity[50],
},
sequence: {
+1 -1
View File
@@ -605,7 +605,7 @@ export const ar: TranslationResources = {
files: "الملفات",
pullRequest: "طلب السحب",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "افتح اللوحة الجانبية",
+1 -1
View File
@@ -603,7 +603,7 @@ export const en = {
files: "Files",
pullRequest: "Pull request",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "Open side panel",
+1 -1
View File
@@ -610,7 +610,7 @@ export const es: TranslationResources = {
files: "Archivos",
pullRequest: "Solicitud de extracción",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "Abrir panel lateral",
+1 -1
View File
@@ -610,7 +610,7 @@ export const fr: TranslationResources = {
files: "Fichiers",
pullRequest: "Demande de fusion",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "Ouvrir le panneau latéral",
+1 -1
View File
@@ -610,7 +610,7 @@ export const ja: TranslationResources = {
files: "ファイル",
pullRequest: "プルリクエスト",
terminalProfilesMenu: "ターミナルプロファイル",
editTerminalProfiles: "プロファイルを編集…",
editTerminalProfiles: "プロファイルを編集",
},
sidePanel: {
open: "サイドパネルを開く",
+1 -1
View File
@@ -607,7 +607,7 @@ export const ko: TranslationResources = {
files: "파일",
pullRequest: "풀 리퀘스트",
terminalProfilesMenu: "터미널 프로필",
editTerminalProfiles: "프로필 편집…",
editTerminalProfiles: "프로필 편집",
},
sidePanel: {
open: "사이드 패널 열기",
+1 -1
View File
@@ -609,7 +609,7 @@ export const ptBR: TranslationResources = {
files: "Arquivos",
pullRequest: "Pull request",
terminalProfilesMenu: "Perfis de terminal",
editTerminalProfiles: "Editar perfis…",
editTerminalProfiles: "Editar perfis",
},
sidePanel: {
open: "Abrir painel lateral",
+1 -1
View File
@@ -610,7 +610,7 @@ export const ru: TranslationResources = {
files: "Файлы",
pullRequest: "PR",
terminalProfilesMenu: "Профили терминала",
editTerminalProfiles: "Изменить профили…",
editTerminalProfiles: "Изменить профили",
},
sidePanel: {
open: "Открыть боковую панель",
+1 -1
View File
@@ -605,7 +605,7 @@ export const zhCN: TranslationResources = {
files: "文件",
pullRequest: "拉取请求",
terminalProfilesMenu: "Terminal profiles",
editTerminalProfiles: "Edit profiles…",
editTerminalProfiles: "Edit profiles",
},
sidePanel: {
open: "打开侧边面板",
+1
View File
@@ -1350,6 +1350,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
+4 -1
View File
@@ -29,6 +29,7 @@ import { openSupportingTab, toggleSupportingTab } from "@/workspace-tabs/side-pa
export const AgentTracks = memo(function AgentTracks({
serverId,
workspaceId,
cwd,
subagentRows,
tasks,
archiveFinishedStatus,
@@ -36,6 +37,7 @@ export const AgentTracks = memo(function AgentTracks({
}: {
serverId: string;
workspaceId: string;
cwd: string;
subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
@@ -99,10 +101,11 @@ export const AgentTracks = memo(function AgentTracks({
toggleSupportingTab({
isCompact,
workspaceKey,
checkout: { serverId, cwd, isGit: true },
target: { kind: "working_diff" },
openInSidePanelByDefault,
});
}, [isCompact, openInSidePanelByDefault, workspaceKey]);
}, [cwd, isCompact, openInSidePanelByDefault, serverId, workspaceKey]);
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
return null;
+548
View File
@@ -0,0 +1,548 @@
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactElement,
type ReactNode,
} from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import {
FileDiff,
FolderTree,
GitPullRequest,
Globe,
Pencil,
Plus,
SquarePen,
SquareTerminal,
type LucideIcon,
} from "lucide-react-native";
import { useRouter, type Href } from "expo-router";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { resolvePluginIcon } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin } from "@/plugins/types";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
export interface NewTabLauncher {
showChanges: boolean;
showPullRequest: boolean;
showBrowser: boolean;
terminalDisabled: boolean;
activate: (tabId: string, selection: NewTabSelection) => void;
}
const NewTabLauncherContext = createContext<NewTabLauncher | null>(null);
export function NewTabLauncherProvider({
value,
children,
}: {
value: NewTabLauncher;
children: ReactNode;
}) {
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
}
const ThemedPlus = withUnistyles(Plus);
const ThemedPencil = withUnistyles(Pencil);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
/** Every launcher row leads with the same glyph box so labels share one rail. */
const LAUNCHER_ICON_SIZE = ICON_SIZE.md;
const LAUNCHER_MAX_WIDTH = 380;
const EDIT_PROFILES_HIT_SIZE = ICON_SIZE.xs + SPACING[2];
const ROW_DATA_SET = { newTabLauncherRow: "true" };
const ROW_SELECTOR = '[data-new-tab-launcher-row="true"]';
const CHANGES_SELECTION: NewTabSelection = {
kind: "target",
target: { kind: "working_diff" },
};
const FILES_SELECTION: NewTabSelection = { kind: "target", target: { kind: "files" } };
const PULL_REQUEST_SELECTION: NewTabSelection = {
kind: "target",
target: { kind: "pull_request" },
};
const AGENT_SELECTION: NewTabSelection = { kind: "agent" };
const TERMINAL_SELECTION: NewTabSelection = { kind: "terminal" };
const BROWSER_SELECTION: NewTabSelection = { kind: "browser" };
function LauncherIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={LAUNCHER_ICON_SIZE} color={color} />;
}
const ThemedLauncherIcon = withUnistyles(LauncherIcon);
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
const keys = useShortcutKeys(actionId);
return keys ? <Shortcut chord={keys} /> : null;
}
function rowStyle({
pressed,
hovered = false,
focused = false,
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) {
return [
styles.row,
(hovered || focused) && styles.rowHovered,
focused && styles.rowFocused,
pressed && styles.rowPressed,
];
}
function editProfilesStyle({ pressed, hovered }: PressableStateCallbackType) {
return [styles.editProfiles, (hovered || pressed) && styles.editProfilesHovered];
}
function EditProfilesButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
style={editProfilesStyle}
testID="workspace-new-tab-edit-terminal-profiles"
>
<View style={styles.editProfilesGlyph}>
<ThemedPencil size={ICON_SIZE.xs} uniProps={extraMutedColorMapping} />
</View>
</Pressable>
);
}
function LauncherRow({
label,
Icon,
terminalIconKey,
disabled,
selection,
shortcut,
testID,
}: {
label: string;
Icon?: LucideIcon;
terminalIconKey?: string;
disabled?: boolean;
selection: NewTabSelection;
shortcut?: string;
testID: string;
}) {
const { tabId } = usePaneContext();
const launcher = useContext(NewTabLauncherContext);
invariant(launcher, "NewTabLauncherProvider is required");
const handlePress = useCallback(() => {
launcher.activate(tabId, selection);
}, [launcher, selection, tabId]);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
dataSet={ROW_DATA_SET}
disabled={disabled}
onPress={handlePress}
style={rowStyle}
tabIndex={-1}
testID={testID}
>
{Icon ? <ThemedLauncherIcon Icon={Icon} uniProps={mutedColorMapping} /> : null}
{terminalIconKey ? (
<TerminalProfileIcon iconKey={terminalIconKey} size={LAUNCHER_ICON_SIZE} />
) : null}
<Text numberOfLines={1} style={styles.rowLabel}>
{label}
</Text>
{shortcut ? <LauncherShortcut actionId={shortcut} /> : null}
</Pressable>
);
}
function TerminalProfileLauncherRow({
profile,
disabled,
}: {
profile: TerminalProfile;
disabled: boolean;
}) {
const selection = useMemo<NewTabSelection>(() => ({ kind: "terminal", profile }), [profile]);
return (
<LauncherRow
label={profile.name}
terminalIconKey={getTerminalProfileIcon(profile)}
disabled={disabled}
selection={selection}
testID={`workspace-new-tab-terminal-profile-${profile.id}`}
/>
);
}
function PluginLauncherRow({
pluginId,
panel,
}: {
pluginId: string;
panel: PluginWorkspacePanelContribution;
}) {
const selection = useMemo<NewTabSelection>(
() => ({
kind: "target",
target: { kind: "plugin", pluginId, panelId: panel.id, context: "workspace" },
}),
[panel.id, pluginId],
);
return (
<LauncherRow
label={panel.title}
Icon={resolvePluginIcon(panel.icon)}
selection={selection}
testID={`workspace-new-tab-plugin-${pluginId}-${panel.id}`}
/>
);
}
function collectWorkspacePluginPanels(plugins: InstalledPlugin[], serverId: string) {
const result: Array<{ pluginId: string; panel: PluginWorkspacePanelContribution }> = [];
for (const plugin of plugins) {
if (plugin.serverId !== serverId) continue;
for (const panel of plugin.workspacePanels) {
if (panel.context === "workspace") {
result.push({ pluginId: plugin.id, panel });
}
}
}
return result;
}
function useNewTabDescriptor() {
const { t } = useTranslation();
const label = t("workspace.tabs.actions.newTab");
return {
label,
subtitle: label,
tooltip: label,
titleState: "ready" as const,
icon: ThemedPlus,
statusBucket: null,
};
}
const NewTabPanel = memo(function NewTabPanel(): ReactElement {
const { t } = useTranslation();
const router = useRouter();
const { serverId, tabId } = usePaneContext();
const { isInteractive, focusPane } = usePaneFocus();
const launcher = useContext(NewTabLauncherContext);
invariant(launcher, "NewTabLauncherProvider is required");
const { config } = useDaemonConfig(serverId);
const plugins = useInstalledPlugins();
const containerRef = useRef<View | null>(null);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const pluginPanels = useMemo(
() => collectWorkspacePluginPanels(plugins, serverId),
[plugins, serverId],
);
const editTerminalProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "terminals") as Href);
}, [router, serverId]);
useEffect(() => {
if (!isWeb || !isInteractive) return;
const container: unknown = containerRef.current;
if (!(container instanceof HTMLElement)) return;
const webContainer = container as HTMLElement;
function rows() {
return Array.from(webContainer.querySelectorAll(ROW_SELECTOR)) as HTMLElement[];
}
function focusFirstRow() {
rows()[0]?.focus({ preventScroll: true });
}
function handlePointerDown(event: PointerEvent) {
focusPane();
if (!(event.target instanceof Element) || !event.target.closest(ROW_SELECTOR)) {
requestAnimationFrame(focusFirstRow);
}
}
function handleKeyDown(event: KeyboardEvent) {
const availableRows = rows();
const currentIndex = availableRows.findIndex((row) => row === document.activeElement);
let direction = 0;
if (event.key === "ArrowDown") {
direction = 1;
} else if (event.key === "ArrowUp") {
direction = -1;
}
if (direction !== 0 && availableRows.length > 0) {
event.preventDefault();
let start = currentIndex;
if (currentIndex < 0) {
start = direction > 0 ? -1 : 0;
}
const nextIndex = (start + direction + availableRows.length) % availableRows.length;
availableRows[nextIndex]?.focus();
}
if (event.key === "Enter" && currentIndex >= 0) {
event.preventDefault();
availableRows[currentIndex]?.click();
}
}
webContainer.addEventListener("pointerdown", handlePointerDown, true);
webContainer.addEventListener("keydown", handleKeyDown);
focusFirstRow();
return () => {
webContainer.removeEventListener("pointerdown", handlePointerDown, true);
webContainer.removeEventListener("keydown", handleKeyDown);
};
}, [focusPane, isInteractive]);
const activate = useCallback(
(selection: NewTabSelection) => launcher.activate(tabId, selection),
[launcher, tabId],
);
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id === "workspace.agent.new" || action.id === "workspace.tab.target.agent") {
activate({ kind: "agent" });
return true;
}
if (action.id === "workspace.terminal.new") {
activate({ kind: "terminal" });
return true;
}
if (action.id === "workspace.browser.new" || action.id === "workspace.tab.target.browser") {
if (launcher.showBrowser) {
activate({ kind: "browser" });
}
return true;
}
if (action.id === "workspace.tab.target.changes") {
if (launcher.showChanges) {
activate({ kind: "target", target: { kind: "working_diff" } });
}
return true;
}
if (action.id === "workspace.tab.target.files") {
activate({ kind: "target", target: { kind: "files" } });
return true;
}
return false;
},
[activate, launcher.showBrowser, launcher.showChanges],
);
useKeyboardActionHandler({
handlerId: `new-tab:${tabId}`,
actions: [
"workspace.agent.new",
"workspace.terminal.new",
"workspace.browser.new",
"workspace.tab.target.agent",
"workspace.tab.target.browser",
"workspace.tab.target.changes",
"workspace.tab.target.files",
],
enabled: isInteractive,
priority: 250,
handle: handleKeyboardAction,
});
return (
<View ref={containerRef} style={styles.container} testID="workspace-new-tab-panel">
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.rail}>
<View style={styles.group}>
{launcher.showChanges ? (
<LauncherRow
label={t("workspace.tabs.actions.changes")}
Icon={FileDiff}
selection={CHANGES_SELECTION}
shortcut="workspace-tab-target-changes"
testID="workspace-new-tab-changes"
/>
) : null}
<LauncherRow
label={t("workspace.tabs.actions.files")}
Icon={FolderTree}
selection={FILES_SELECTION}
shortcut="workspace-tab-target-files"
testID="workspace-new-tab-files"
/>
{launcher.showPullRequest ? (
<LauncherRow
label={t("workspace.tabs.actions.pullRequest")}
Icon={GitPullRequest}
selection={PULL_REQUEST_SELECTION}
testID="workspace-new-tab-pull-request"
/>
) : null}
<LauncherRow
label={t("workspace.tabs.fallback.agent")}
Icon={SquarePen}
selection={AGENT_SELECTION}
shortcut="workspace-tab-target-agent"
testID="workspace-new-tab-agent"
/>
<LauncherRow
label={t("workspace.tabs.fallback.terminal")}
Icon={SquareTerminal}
disabled={launcher.terminalDisabled}
selection={TERMINAL_SELECTION}
shortcut="workspace-terminal-new"
testID="workspace-new-tab-terminal"
/>
{launcher.showBrowser ? (
<LauncherRow
label={t("workspace.tabs.fallback.browser")}
Icon={Globe}
selection={BROWSER_SELECTION}
shortcut="workspace-tab-target-browser"
testID="workspace-new-tab-browser"
/>
) : null}
{pluginPanels.map(({ pluginId, panel }) => (
<PluginLauncherRow
key={`${pluginId}:${panel.id}`}
pluginId={pluginId}
panel={panel}
/>
))}
</View>
{profiles.length > 0 ? (
<View style={styles.group}>
<View style={styles.groupHeader}>
<Text style={styles.groupLabel}>
{t("workspace.tabs.actions.terminalProfilesMenu")}
</Text>
<EditProfilesButton
label={t("workspace.tabs.actions.editTerminalProfiles")}
onPress={editTerminalProfiles}
/>
</View>
{profiles.map((profile) => (
<TerminalProfileLauncherRow
key={profile.id}
disabled={launcher.terminalDisabled}
profile={profile}
/>
))}
</View>
) : null}
</View>
</ScrollView>
</View>
);
});
export const newTabPanelRegistration: PanelRegistration<"new_tab"> = {
kind: "new_tab",
resourceKey: () => "new_tab",
component: NewTabPanel,
useDescriptor: useNewTabDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[6],
},
rail: {
width: "100%",
maxWidth: LAUNCHER_MAX_WIDTH,
gap: theme.spacing[12],
},
group: {
gap: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 44,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
backgroundColor: theme.colors.surface1,
},
rowHovered: { backgroundColor: theme.colors.surface2 },
rowFocused: { borderColor: theme.colors.borderAccent },
rowPressed: { opacity: 0.85 },
rowLabel: { flex: 1, color: theme.colors.foreground },
// The rows below establish both rails at `spacing[3] + borderWidth[1]`: their icon
// glyphs on the left, their shortcut badges on the right. The label sits on the
// first directly; the pencil's own padding is subtracted so its glyph — not its
// hover box — lands on the second.
groupHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingLeft: theme.spacing[3] + theme.borderWidth[1],
// Optical: the pencil's stroke stops short of its own box, so the box rail
// needs a pixel back to put the ink on the rows' trailing rail.
paddingRight: theme.spacing[3] + theme.borderWidth[1] + 3,
marginBottom: theme.spacing[1],
},
groupLabel: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
// Without an explicit line height the font's ascent/descent split the text box
// unevenly and the label rides above the pencil beside it.
lineHeight: EDIT_PROFILES_HIT_SIZE,
},
// The box is the hover target; the negative margin cancels its growth on every
// side, so its layout footprint stays the glyph and the header keeps its height.
editProfiles: {
width: EDIT_PROFILES_HIT_SIZE,
height: EDIT_PROFILES_HIT_SIZE,
margin: -(EDIT_PROFILES_HIT_SIZE - ICON_SIZE.xs) / 2,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
outlineWidth: 0,
outlineColor: "transparent",
},
// Opacity sits on the whole glyph, not per path — the pencil's two strokes
// overlap and would render unevenly if each faded on its own.
editProfilesGlyph: {
opacity: theme.opacity[50],
},
editProfilesHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -10,6 +10,7 @@ import { terminalPanelRegistration } from "@/panels/terminal-panel";
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
import { pullRequestPanelRegistration } from "@/panels/pull-request-panel";
import { pluginPanelRegistration } from "@/plugins/workspace-panels/panel";
import { newTabPanelRegistration } from "@/panels/new-tab-panel";
let panelsRegistered = false;
@@ -18,6 +19,7 @@ export function ensurePanelsRegistered(): void {
return;
}
registerPanel(draftPanelRegistration);
registerPanel(newTabPanelRegistration);
registerPanel(agentPanelRegistration);
registerPanel(providerSubagentPanelRegistration);
registerPanel(setupPanelRegistration);
@@ -19,8 +19,12 @@ import {
upsertCreatedTerminalPayload,
} from "@/screens/workspace/terminals/state";
export type TerminalTabDestination =
| { kind: "open"; paneId?: string }
| { kind: "replace"; tabId: string };
interface PendingTerminalCreateInput {
paneId?: string;
destination: TerminalTabDestination;
profile?: TerminalProfile;
}
@@ -34,7 +38,7 @@ interface UseWorkspaceTerminalsInput {
workspaceScripts: WorkspaceDescriptor["scripts"];
hasHydratedWorkspaces: boolean;
isMissingWorkspaceDirectory: boolean;
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
onTerminalCreated: (input: { terminalId: string; destination: TerminalTabDestination }) => void;
onScriptTerminalSelected: (terminalId: string) => void;
onWorkspacePathUnavailable: () => void;
onTerminalCreateQueued: () => void;
@@ -125,13 +129,11 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
);
const createMutation = useMutation({
mutationFn: async (_input?: PendingTerminalCreateInput) => {
mutationFn: async (_input: PendingTerminalCreateInput) => {
if (!client || !workspaceDirectory) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const profile = _input?.profile
? resolveTerminalProfileLaunch(_input.profile, "")
: undefined;
const profile = _input.profile ? resolveTerminalProfileLaunch(_input.profile, "") : undefined;
const payload = profile
? await client.createTerminal(workspaceDirectory, profile.name, undefined, {
command: profile.command,
@@ -165,7 +167,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
if (createdTerminal) {
onTerminalCreated({
terminalId: createdTerminal.id,
paneId: createInput?.paneId,
destination: createInput.destination,
});
}
},
@@ -212,7 +214,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
]);
const createTerminal = useCallback(
(createInput?: PendingTerminalCreateInput) => {
(createInput: PendingTerminalCreateInput) => {
if (createMutation.isPending || pendingCreateInput) {
return;
}
@@ -227,7 +229,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
return;
}
setPendingCreateInput(createInput ?? {});
setPendingCreateInput(createInput);
onTerminalCreateQueued();
},
[
@@ -24,15 +24,9 @@ import {
Copy,
Pencil,
RotateCw,
Globe,
FileDiff,
FolderTree,
GitPullRequest,
Maximize2,
Minimize2,
Plus,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -44,7 +38,6 @@ import Animated, {
} from "react-native-reanimated";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { useTranslation } from "react-i18next";
import { useRouter, type Href } from "expo-router";
import { SortableInlineList } from "@/components/sortable-inline-list";
import type {
DraggableListDragHandleProps,
@@ -58,14 +51,6 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -87,29 +72,13 @@ import {
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useSettings } from "@/hooks/use-settings";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { openSupportingTab } from "@/workspace-tabs/side-panel";
const DROPDOWN_WIDTH = 220;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
@@ -196,30 +165,13 @@ const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
const ThemedCopyX = withUnistyles(CopyX);
const ThemedPencil = withUnistyles(Pencil);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedPlus = withUnistyles(Plus);
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedMaximize2 = withUnistyles(Maximize2);
const ThemedMinimize2 = withUnistyles(Minimize2);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const AGENT_ICON = <ThemedSquarePen size={14} uniProps={mutedColorMapping} />;
const TERMINAL_ICON = <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
const BROWSER_ICON = <ThemedGlobe size={14} uniProps={mutedColorMapping} />;
const CHANGES_ICON = <ThemedFileDiff size={14} uniProps={mutedColorMapping} />;
const FILES_ICON = <ThemedFolderTree size={14} uniProps={mutedColorMapping} />;
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={14} uniProps={mutedColorMapping} />;
const CHANGES_TARGET = { kind: "working_diff" } as const;
const FILES_TARGET = { kind: "files" } as const;
const PULL_REQUEST_TARGET = { kind: "pull_request" } as const;
function inlineAddActionButtonStyle({
hovered,
pressed,
@@ -264,193 +216,37 @@ function TabLabelMeasurement({
);
}
interface TerminalProfileMenuItemProps {
profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
disabled?: boolean;
onLaunch: (profile: TerminalProfile) => void;
}
function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) {
const leading = useMemo(
() => <TerminalProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
[profile],
);
const handleSelect = useCallback(() => onLaunch(profile), [onLaunch, profile]);
return (
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
{profile.name}
</DropdownMenuItem>
);
}
interface TabTargetLauncherOptions {
normalizedServerId: string;
onCreateAgentTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onOpenChanges: () => void;
onOpenFiles: () => void;
onOpenPullRequest: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
}
interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions {
open: boolean;
onOpenChange: (open: boolean) => void;
interface WorkspaceNewTabButtonProps {
shortcutKeys: ShortcutKey[][] | null;
onEditProfiles: () => void;
showCreateBrowserTab: boolean;
terminalDisabled: boolean;
isGit: boolean;
showPullRequest: boolean;
onPress: () => void;
onLayout: (event: LayoutChangeEvent) => void;
}
function WorkspaceNewTabButton({
open,
onOpenChange,
shortcutKeys,
onCreateAgentTab,
onCreateTerminal,
onCreateBrowser,
onOpenChanges,
onOpenFiles,
onOpenPullRequest,
onCreateTerminalWithProfile,
onEditProfiles,
normalizedServerId,
showCreateBrowserTab,
terminalDisabled,
isGit,
showPullRequest,
onLayout,
}: WorkspaceNewTabButtonProps) {
function WorkspaceNewTabButton({ shortcutKeys, onPress, onLayout }: WorkspaceNewTabButtonProps) {
const { t } = useTranslation();
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const agentKeys = useShortcutKeys("workspace-tab-target-agent");
const terminalKeys = useShortcutKeys("workspace-terminal-new");
const browserKeys = useShortcutKeys("workspace-tab-target-browser");
const changesKeys = useShortcutKeys("workspace-tab-target-changes");
const filesKeys = useShortcutKeys("workspace-tab-target-files");
const tooltipText = t("workspace.tabs.actions.newTab");
const agentShortcut = useMemo(
() => (agentKeys ? <Shortcut chord={agentKeys} /> : undefined),
[agentKeys],
);
const terminalShortcut = useMemo(
() => (terminalKeys ? <Shortcut chord={terminalKeys} /> : undefined),
[terminalKeys],
);
const browserShortcut = useMemo(
() => (browserKeys ? <Shortcut chord={browserKeys} /> : undefined),
[browserKeys],
);
const changesShortcut = useMemo(
() => (changesKeys ? <Shortcut chord={changesKeys} /> : undefined),
[changesKeys],
);
const filesShortcut = useMemo(
() => (filesKeys ? <Shortcut chord={filesKeys} /> : undefined),
[filesKeys],
);
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<DropdownMenuTrigger
testID="workspace-new-tab-menu-trigger"
accessibilityRole="button"
accessibilityLabel={tooltipText}
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="bottom" align="start" offset={4} minWidth={200}>
<DropdownMenuItem
testID="workspace-new-tab-menu-agent"
leading={AGENT_ICON}
trailing={agentShortcut}
onSelect={onCreateAgentTab}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<Pressable
testID="workspace-new-tab-button"
accessibilityRole="button"
accessibilityLabel={tooltipText}
onPress={onPress}
style={inlineAddActionButtonStyle}
>
{t("workspace.tabs.fallback.agent")}
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-new-tab-menu-terminal"
leading={TERMINAL_ICON}
disabled={terminalDisabled}
trailing={terminalShortcut}
onSelect={terminalDisabled ? undefined : onCreateTerminal}
>
{t("workspace.tabs.fallback.terminal")}
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-new-tab-menu-browser"
leading={BROWSER_ICON}
trailing={browserShortcut}
onSelect={onCreateBrowser}
>
{t("workspace.tabs.fallback.browser")}
</DropdownMenuItem>
) : null}
{isGit ? (
<DropdownMenuItem
testID="workspace-new-tab-menu-changes"
leading={CHANGES_ICON}
trailing={changesShortcut}
onSelect={onOpenChanges}
>
{t("workspace.tabs.actions.changes")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="workspace-new-tab-menu-files"
leading={FILES_ICON}
trailing={filesShortcut}
onSelect={onOpenFiles}
>
{t("workspace.tabs.actions.files")}
</DropdownMenuItem>
{showPullRequest ? (
<DropdownMenuItem
testID="workspace-new-tab-menu-pull-request"
leading={PULL_REQUEST_ICON}
onSelect={onOpenPullRequest}
>
{t("workspace.tabs.actions.pullRequest")}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
{profiles.map((profile) => (
<TerminalProfileMenuItem
key={profile.id}
profile={profile}
disabled={terminalDisabled}
onLaunch={onCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuItem testID="workspace-new-tab-menu-edit-profiles" onSelect={onEditProfiles}>
{t("workspace.tabs.actions.editTerminalProfiles")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
@@ -669,12 +465,7 @@ interface WorkspaceDesktopTabsRowProps {
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
disableCreateTerminal?: boolean;
isWaitingOnTerminalReadiness?: boolean;
onCreateNewTab: (input: { paneId?: string }) => void;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
externalDndContext?: boolean;
activeDragTabId?: string | null;
@@ -736,6 +527,7 @@ function WorkspaceDesktopTabPresentationCommit({
function getFallbackTabLabel(
tab: WorkspaceTabDescriptor,
labels: {
newTab: string;
newAgent: string;
setup: string;
terminal: string;
@@ -745,6 +537,9 @@ function getFallbackTabLabel(
pullRequest: string;
},
): string {
if (tab.target.kind === "new_tab") {
return labels.newTab;
}
if (tab.target.kind === "draft") {
return labels.newAgent;
}
@@ -963,6 +758,8 @@ function TabChip({
);
const tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
const testIdentity =
tab.target.kind === "new_tab" ? tab.tabId : buildDeterministicWorkspaceTabId(tab.target);
const tabLabelSkeletonStyle = styles.tabLabelSkeleton;
const tabLabelStyle = useMemo(
() => [styles.tabLabel, isHighlighted && styles.tabLabelActive],
@@ -982,7 +779,7 @@ function TabChip({
<ContextMenuTrigger
{...(dragHandleProps?.attributes as object | undefined)}
{...(dragHandleProps?.listeners as object | undefined)}
testID={`workspace-tab-${buildDeterministicWorkspaceTabId(tab.target)}`}
testID={`workspace-tab-${testIdentity}`}
triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined}
enabledOnMobile={false}
style={tabChipStyle}
@@ -1000,7 +797,7 @@ function TabChip({
backdrop={chipBackdrop}
tabLabelSkeletonStyle={tabLabelSkeletonStyle}
tabLabelStyle={tabLabelStyle}
modifiedTestId={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
modifiedTestId={`workspace-tab-modified-${testIdentity}`}
/>
</ContextMenuTrigger>
</TooltipTrigger>
@@ -1009,7 +806,7 @@ function TabChip({
align="center"
offset={8}
maxWidth={720}
testID={`workspace-tab-tooltip-${buildDeterministicWorkspaceTabId(tab.target)}`}
testID={`workspace-tab-tooltip-${testIdentity}`}
>
{tab.target.kind === "agent" ? (
<View style={styles.tooltipAgentRow}>
@@ -1153,12 +950,7 @@ function ResolvedWorkspaceDesktopTabsRow({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab = false,
disableCreateTerminal = false,
isWaitingOnTerminalReadiness = false,
onCreateNewTab,
onReorderTabs,
externalDndContext = false,
activeDragTabId = null,
@@ -1170,17 +962,11 @@ function ResolvedWorkspaceDesktopTabsRow({
onExitFocusMode,
}: ResolvedWorkspaceDesktopTabsRowProps) {
const { t } = useTranslation();
const router = useRouter();
const isCompact = useIsCompactFormFactor();
const openInSidePanelByDefault = useSettings(
(settings) => settings.openSupportingTabsInSidePanel,
);
const newTabKeys = useShortcutKeys("workspace-tab-new");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const [paneMaximizeButtonWidth, setPaneMaximizeButtonWidth] = useState<number>(0);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const [newTabMenuOpen, setNewTabMenuOpen] = useState(false);
const tabScrollOffset = useSharedValue(0);
const tabScrollViewportWidth = useSharedValue(0);
const tabScrollContentWidth = useSharedValue(0);
@@ -1188,26 +974,6 @@ function ResolvedWorkspaceDesktopTabsRow({
() => new Map<string, WorkspaceTabLabelMeasurement>(),
);
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null);
const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? "";
const checkoutStatus = useCheckoutStatusQuery({
serverId: normalizedServerId,
cwd: workspaceRoot,
});
const isGit = checkoutStatus.status?.isGit === true;
const showPullRequest = isGit;
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openNewTab = useCallback(
(
requestedWorkspaceKey: string,
target: WorkspaceTabTarget,
placement?: WorkspaceTabPlacement,
) => openTab({ workspaceKey: requestedWorkspaceKey, target, intent: "new", placement }),
[openTab],
);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsContainerWidth, event);
@@ -1284,6 +1050,7 @@ function ResolvedWorkspaceDesktopTabsRow({
const fallbackTabLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
@@ -1421,118 +1188,28 @@ function ResolvedWorkspaceDesktopTabsRow({
});
}, [paneId]);
const handleCreateAgentTab = useCallback(() => {
onCreateDraftTab({ paneId });
}, [onCreateDraftTab, paneId]);
const handleCreateTerminal = useCallback(() => {
onCreateTerminalTab({ paneId });
}, [onCreateTerminalTab, paneId]);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfile) => {
onCreateTerminalTab({ paneId, profile });
},
[onCreateTerminalTab, paneId],
);
const handleEditProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href);
}, [normalizedServerId, router]);
const handleCreateBrowser = useCallback(() => {
onCreateBrowserTab({ paneId });
}, [onCreateBrowserTab, paneId]);
const openPanelTarget = useCallback(
(target: { kind: "working_diff" } | { kind: "files" } | { kind: "pull_request" }) => {
if (!workspaceKey) {
return;
}
// Picked from this pane's own menu, so the tab belongs here even if it is
// currently open somewhere else.
openNewTab(workspaceKey, target, paneId ? { mode: "pane", paneId } : undefined);
},
[openNewTab, paneId, workspaceKey],
);
const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]);
const handleOpenChangesShortcut = useCallback(() => {
openSupportingTab({
isCompact,
workspaceKey,
target: CHANGES_TARGET,
openInSidePanelByDefault,
});
}, [isCompact, openInSidePanelByDefault, workspaceKey]);
const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]);
const handleOpenPullRequest = useCallback(
() => openPanelTarget(PULL_REQUEST_TARGET),
[openPanelTarget],
);
const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness;
useEffect(() => {
if (!isFocused) {
setNewTabMenuOpen(false);
}
}, [isFocused]);
const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]);
const handleNewTabKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!isFocused) return false;
if (action.id === "workspace.tab.menu.open") {
setNewTabMenuOpen(true);
createNewTab();
return true;
}
switch (action.id) {
case "workspace.tab.target.agent":
setNewTabMenuOpen(false);
handleCreateAgentTab();
return true;
case "workspace.tab.target.browser":
if (!showCreateBrowserTab) return true;
setNewTabMenuOpen(false);
handleCreateBrowser();
return true;
case "workspace.tab.target.changes":
if (!isGit) return true;
setNewTabMenuOpen(false);
handleOpenChangesShortcut();
return true;
case "workspace.tab.target.files":
setNewTabMenuOpen(false);
handleOpenFiles();
return true;
default:
return false;
}
return false;
},
[
handleCreateAgentTab,
handleCreateBrowser,
handleOpenChangesShortcut,
handleOpenFiles,
isFocused,
isGit,
showCreateBrowserTab,
],
[createNewTab, isFocused],
);
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-new-tab-menu",
name: "workspace-new-tab",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
}),
actions: [
"workspace.tab.menu.open",
"workspace.tab.target.agent",
"workspace.tab.target.browser",
"workspace.tab.target.changes",
"workspace.tab.target.files",
],
actions: ["workspace.tab.menu.open"],
enabled: isFocused,
priority: 200,
handle: handleNewTabKeyboardAction,
@@ -1670,22 +1347,8 @@ function ResolvedWorkspaceDesktopTabsRow({
/>
{!layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton
open={newTabMenuOpen}
onOpenChange={setNewTabMenuOpen}
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit}
showPullRequest={showPullRequest}
onPress={createNewTab}
onLayout={handleInlineAddButtonLayout}
/>
) : null}
@@ -1698,22 +1361,8 @@ function ResolvedWorkspaceDesktopTabsRow({
</View>
{layout.requiresHorizontalScrollFallback ? (
<WorkspaceNewTabButton
open={newTabMenuOpen}
onOpenChange={setNewTabMenuOpen}
shortcutKeys={newTabKeys}
onCreateAgentTab={handleCreateAgentTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowser}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onOpenChanges={handleOpenChanges}
onOpenFiles={handleOpenFiles}
onOpenPullRequest={handleOpenPullRequest}
onEditProfiles={handleEditProfiles}
normalizedServerId={normalizedServerId}
showCreateBrowserTab={showCreateBrowserTab}
terminalDisabled={terminalDisabled}
isGit={isGit}
showPullRequest={showPullRequest}
onPress={createNewTab}
onLayout={handleInlineAddButtonLayout}
/>
) : null}
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldSeedEmptyWorkspaceDraft } from "./workspace-empty-draft-seed";
const readyEmptyWorkspace = {
isRouteFocused: true,
hasPersistenceKey: true,
hasWorkspaceDirectory: true,
hasHydratedWorkspaceLayoutStore: true,
hasHydratedAgents: true,
hasLoadedTerminals: true,
activeAgentCount: 0,
terminalCount: 0,
tabCount: 0,
};
describe("shouldSeedEmptyWorkspaceDraft", () => {
it("waits for refresh-time hydration before seeding a draft", () => {
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
hasHydratedWorkspaceLayoutStore: false,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
hasHydratedAgents: false,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
hasLoadedTerminals: false,
}),
).toBe(false);
});
it("does not seed when existing workspace content is known", () => {
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
activeAgentCount: 1,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
terminalCount: 1,
}),
).toBe(false);
expect(
shouldSeedEmptyWorkspaceDraft({
...readyEmptyWorkspace,
tabCount: 1,
}),
).toBe(false);
});
it("seeds once an empty focused workspace is fully known", () => {
expect(shouldSeedEmptyWorkspaceDraft(readyEmptyWorkspace)).toBe(true);
});
});
@@ -1,24 +0,0 @@
export function shouldSeedEmptyWorkspaceDraft(input: {
isRouteFocused: boolean;
hasPersistenceKey: boolean;
hasWorkspaceDirectory: boolean;
hasHydratedWorkspaceLayoutStore: boolean;
hasHydratedAgents: boolean;
hasLoadedTerminals: boolean;
activeAgentCount: number;
terminalCount: number;
tabCount: number;
}): boolean {
if (
!input.isRouteFocused ||
!input.hasPersistenceKey ||
!input.hasWorkspaceDirectory ||
!input.hasHydratedWorkspaceLayoutStore ||
!input.hasHydratedAgents ||
!input.hasLoadedTerminals
) {
return false;
}
return input.activeAgentCount === 0 && input.terminalCount === 0 && input.tabCount === 0;
}
@@ -1,277 +0,0 @@
import { memo, useCallback, useEffect, useRef, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import {
FileDiff,
FolderTree,
GitPullRequest,
SquarePen,
SquareTerminal,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { Theme } from "@/styles/theme";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const FILES_ICON = <ThemedFolderTree size={16} uniProps={mutedColorMapping} />;
const CHANGES_ICON = <ThemedFileDiff size={16} uniProps={mutedColorMapping} />;
const PULL_REQUEST_ICON = <ThemedGitPullRequest size={16} uniProps={mutedColorMapping} />;
const AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
const LAUNCHER_ROW_DATA_SET = { emptyPaneLauncherRow: "true" };
/** The launcher's own binding, or nothing when the action has no shortcut. */
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
const keys = useShortcutKeys(actionId);
return keys ? <Shortcut chord={keys} /> : null;
}
const LAUNCHER_ROW_SELECTOR = '[data-empty-pane-launcher-row="true"]';
function getLauncherRowStyle({
pressed,
hovered = false,
focused = false,
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) {
return [
styles.row,
(hovered || focused) && styles.rowHovered,
focused && styles.rowFocused,
pressed && styles.pressed,
];
}
/**
* One launcher. This is a row with a trailing slot, not a `<Button>` — the shortcut
* badge sits opposite the label on a shared rail, which is the list-row anatomy in
* `docs/design.md` §12 rather than anything the button primitive can express.
*/
function LauncherRow({
label,
icon,
onPress,
children,
}: {
label: string;
icon: ReactNode;
onPress: () => void;
children?: ReactNode;
}): ReactElement {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
dataSet={LAUNCHER_ROW_DATA_SET}
tabIndex={-1}
onPress={onPress}
style={getLauncherRowStyle}
>
{icon}
<Text style={styles.rowLabel}>{label}</Text>
<View style={styles.rowSpacer} />
{children}
</Pressable>
);
}
export interface WorkspacePaneEmptyStateProps {
paneId: string;
/** Logical pane focus. On desktop, the launcher turns this into DOM focus. */
isFocused: boolean;
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
showChanges: boolean;
showPullRequest: boolean;
onOpenView: (input: { paneId: string; target: WorkspaceTabTarget }) => void;
onCreateAgent: (input: { paneId: string }) => void;
onCreateTerminal: (input: { paneId: string }) => void;
/**
* Dismisses the pane. Absent on the last pane standing, where there is nothing
* for the layout to fall back to. What "close" means to this pane — hiding the
* Side panel, removing an ordinary split — belongs to the layout store.
*/
onClosePane: (() => void) | null;
}
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
paneId,
isFocused,
showChanges,
showPullRequest,
onOpenView,
onCreateAgent,
onCreateTerminal,
onClosePane,
}: WorkspacePaneEmptyStateProps): ReactElement {
const { t } = useTranslation();
const containerRef = useRef<View | null>(null);
useEffect(() => {
if (!isWeb || !isFocused) return;
const container = containerRef.current;
if (!(container instanceof HTMLElement)) return;
const handleKeyDown = (event: KeyboardEvent) => {
const rows = Array.from(container.querySelectorAll<HTMLElement>(LAUNCHER_ROW_SELECTOR));
if (rows.length === 0) return;
const currentIndex = rows.findIndex((row) => row === document.activeElement);
let nextIndex: number | null = null;
if (event.key === "ArrowDown") {
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % rows.length;
}
if (event.key === "ArrowUp") {
nextIndex =
currentIndex < 0 ? rows.length - 1 : (currentIndex - 1 + rows.length) % rows.length;
}
if (nextIndex !== null) {
event.preventDefault();
event.stopPropagation();
rows[nextIndex]?.focus();
return;
}
if (event.key === "Enter" && currentIndex >= 0) {
event.preventDefault();
event.stopPropagation();
rows[currentIndex]?.click();
}
};
container.addEventListener("keydown", handleKeyDown);
container.querySelector<HTMLElement>(LAUNCHER_ROW_SELECTOR)?.focus({ preventScroll: true });
return () => container.removeEventListener("keydown", handleKeyDown);
}, [isFocused]);
const openFiles = useCallback(
() => onOpenView({ paneId, target: FILES_TARGET }),
[onOpenView, paneId],
);
const openChanges = useCallback(
() => onOpenView({ paneId, target: CHANGES_TARGET }),
[onOpenView, paneId],
);
const openPullRequest = useCallback(
() => onOpenView({ paneId, target: PULL_REQUEST_TARGET }),
[onOpenView, paneId],
);
const createAgent = useCallback(() => onCreateAgent({ paneId }), [onCreateAgent, paneId]);
const createTerminal = useCallback(
() => onCreateTerminal({ paneId }),
[onCreateTerminal, paneId],
);
return (
<View ref={containerRef} style={styles.container} testID="workspace-pane-empty-state">
<View style={styles.rail}>
<View style={styles.stack}>
{showChanges ? (
<LauncherRow
label={t("workspace.tabs.actions.changes")}
icon={CHANGES_ICON}
onPress={openChanges}
>
<LauncherShortcut actionId="workspace-tab-target-changes" />
</LauncherRow>
) : null}
<LauncherRow
label={t("workspace.tabs.actions.files")}
icon={FILES_ICON}
onPress={openFiles}
>
<LauncherShortcut actionId="workspace-tab-target-files" />
</LauncherRow>
{showPullRequest ? (
<LauncherRow
label={t("workspace.tabs.actions.pullRequest")}
icon={PULL_REQUEST_ICON}
onPress={openPullRequest}
/>
) : null}
<LauncherRow
label={t("workspace.tabs.fallback.agent")}
icon={AGENT_ICON}
onPress={createAgent}
>
<LauncherShortcut actionId="workspace-tab-target-agent" />
</LauncherRow>
<LauncherRow
label={t("workspace.tabs.fallback.terminal")}
icon={TERMINAL_ICON}
onPress={createTerminal}
>
<LauncherShortcut actionId="workspace-terminal-new" />
</LauncherRow>
</View>
{onClosePane ? (
<Button variant="ghost" size="sm" style={styles.closePane} onPress={onClosePane}>
{t("workspace.tabs.actions.closePane")}
</Button>
) : null}
</View>
</View>
);
});
// Wide enough for a shortcut badge to sit clear of the longest label, narrow enough
// that the stack still reads as one column in a half-width pane.
const LAUNCHER_RAIL_WIDTH = 380;
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[6],
},
rail: {
width: "100%",
maxWidth: LAUNCHER_RAIL_WIDTH,
gap: theme.spacing[3],
},
stack: {
gap: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 44,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
backgroundColor: theme.colors.surface2,
},
rowHovered: {
backgroundColor: theme.colors.surface3,
},
rowFocused: {
borderColor: theme.colors.borderAccent,
},
pressed: {
opacity: 0.85,
},
rowLabel: {
color: theme.colors.foreground,
},
rowSpacer: {
flex: 1,
},
closePane: {
alignSelf: "center",
},
}));
@@ -82,11 +82,7 @@ import type {
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { selectVisibleAgentIds } from "./visible-agent-ids";
import {
getHostRuntimeStore,
@@ -125,7 +121,6 @@ import {
WorkspaceTabRenameModal,
} from "@/screens/workspace/use-workspace-tab-rename";
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
import { WorkspacePaneEmptyState } from "@/screens/workspace/workspace-pane-empty-state";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
@@ -160,7 +155,9 @@ import {
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { WorkspaceFocusProvider } from "@/workspace/focus";
import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed";
import { NewTabLauncherProvider, type NewTabLauncher } from "@/panels/new-tab-panel";
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
import type { TerminalTabDestination } from "@/screens/workspace/terminals/use-workspace-terminals";
import {
buildBulkCloseConfirmationMessage,
type BulkCloseConfirmationLabels,
@@ -297,6 +294,7 @@ function useSyncWorkspaceActiveBrowser(input: {
function getFallbackTabOptionLabel(
tab: WorkspaceTabDescriptor,
labels: {
newTab: string;
newAgent: string;
setup: string;
terminal: string;
@@ -307,6 +305,9 @@ function getFallbackTabOptionLabel(
pullRequest: string;
},
): string {
if (tab.target.kind === "new_tab") {
return labels.newTab;
}
if (tab.target.kind === "draft") {
return labels.newAgent;
}
@@ -340,6 +341,7 @@ function getFallbackTabOptionLabel(
function getFallbackTabOptionDescription(
tab: WorkspaceTabDescriptor,
labels: {
newTab: string;
newAgent: string;
workspaceSetup: string;
agent: string;
@@ -350,6 +352,9 @@ function getFallbackTabOptionDescription(
pullRequest: string;
},
): string {
if (tab.target.kind === "new_tab") {
return labels.newTab;
}
if (tab.target.kind === "draft") {
return labels.newAgent;
}
@@ -551,7 +556,7 @@ function MobileWorkspaceTabOption({
}),
[t],
);
const menuTestIDBase = `workspace-tab-menu-${buildDeterministicWorkspaceTabId(tab.target)}`;
const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
@@ -573,6 +578,7 @@ function MobileWorkspaceTabOption({
const fallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
@@ -1057,6 +1063,7 @@ interface RenderWorkspaceContentInput {
isMissingWorkspaceDirectory: boolean;
activeTabDescriptor: WorkspaceTabDescriptor | null;
hasHydratedAgents: boolean;
hasLoadedTerminals: boolean;
mountedFocusedPaneTabIds: string[];
focusedPaneTabDescriptorMap: Map<string, WorkspaceTabDescriptor>;
isRouteFocused: boolean;
@@ -1072,6 +1079,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
isMissingWorkspaceDirectory,
activeTabDescriptor,
hasHydratedAgents,
hasLoadedTerminals,
mountedFocusedPaneTabIds,
focusedPaneTabDescriptorMap,
isRouteFocused,
@@ -1088,7 +1096,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
</View>
);
}
if (!activeTabDescriptor && !hasHydratedAgents) {
if (!hasHydratedAgents || !hasLoadedTerminals) {
return (
<View style={styles.emptyState}>
<ThemedLoadingSpinner uniProps={mutedColorMapping} />
@@ -1270,6 +1278,20 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
);
}
function WorkspacePanelContent({
launcher,
content,
}: {
launcher: NewTabLauncher;
content: ReactNode;
}) {
return (
<NewTabLauncherProvider value={launcher}>
<View style={styles.content}>{content}</View>
</NewTabLauncherProvider>
);
}
function renderWorkspaceScreenGateShell(input: {
gate: ReactNode;
workspaceKey: string | null;
@@ -1355,6 +1377,11 @@ interface WorkspaceTerminalTabActionsInput {
target: WorkspaceTabTarget,
placement?: WorkspaceTabPlacement,
) => string | null;
replaceWorkspaceTabTarget: (
workspaceKey: string,
tabId: string,
target: WorkspaceTabTarget,
) => string | null;
labels: {
workspacePathUnavailable: string;
terminalQueued: string;
@@ -1366,7 +1393,10 @@ interface WorkspaceTerminalTabActionsInput {
}
interface WorkspaceTerminalTabActions {
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
handleTerminalCreated: (input: {
terminalId: string;
destination: TerminalTabDestination;
}) => void;
handleScriptTerminalSelected: (terminalId: string) => void;
handleWorkspacePathUnavailable: () => void;
handleTerminalCreateQueued: () => void;
@@ -1376,21 +1406,29 @@ interface WorkspaceTerminalTabActions {
function useWorkspaceTerminalTabActions({
persistenceKey,
openWorkspaceTabFocused,
replaceWorkspaceTabTarget,
labels,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
({ terminalId, destination }: { terminalId: string; destination: TerminalTabDestination }) => {
if (!persistenceKey) {
return;
}
if (destination.kind === "replace") {
replaceWorkspaceTabTarget(persistenceKey, destination.tabId, {
kind: "terminal",
terminalId,
});
return;
}
openWorkspaceTabFocused(
persistenceKey,
{ kind: "terminal", terminalId },
paneLocalPlacement(paneId),
paneLocalPlacement(destination.paneId),
);
},
[openWorkspaceTabFocused, persistenceKey],
[openWorkspaceTabFocused, persistenceKey, replaceWorkspaceTabTarget],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
@@ -1516,6 +1554,7 @@ function WorkspaceScreenContent({
[normalizedServerId, normalizedWorkspaceId],
);
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const replaceWorkspaceTabTarget = useWorkspaceLayoutStore((state) => state.replaceTab);
const openWorkspaceTabFocused = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
openTab({ workspaceKey, target, intent: "reveal", placement }),
@@ -1585,6 +1624,7 @@ function WorkspaceScreenContent({
} = useWorkspaceTerminalTabActions({
persistenceKey,
openWorkspaceTabFocused,
replaceWorkspaceTabTarget,
labels: {
workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"),
terminalQueued: t("workspace.header.toasts.terminalQueued"),
@@ -1606,7 +1646,6 @@ function WorkspaceScreenContent({
queryKey: terminalsQueryKey,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
} = useWorkspaceTerminals({
client,
isConnected,
@@ -1740,7 +1779,6 @@ function WorkspaceScreenContent({
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const replaceWorkspaceTabTarget = useWorkspaceLayoutStore((state) => state.replaceTab);
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
@@ -1895,7 +1933,7 @@ function WorkspaceScreenContent({
[openWorkspaceTabFocused, openWorkspaceTabInBackground, persistenceKey],
);
useEffect(() => {
useLayoutEffect(() => {
if (!isRouteFocused) {
return;
}
@@ -1985,8 +2023,6 @@ function WorkspaceScreenContent({
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
);
const emptyWorkspaceSeedRef = useRef<string | null>(null);
useEffect(() => {
if (!isRouteFocused) {
return;
@@ -2049,46 +2085,6 @@ function WorkspaceScreenContent({
normalizedWorkspaceId,
]);
useEffect(() => {
if (
!shouldSeedEmptyWorkspaceDraft({
isRouteFocused,
hasPersistenceKey: Boolean(persistenceKey),
hasWorkspaceDirectory: Boolean(workspaceDirectory),
hasHydratedWorkspaceLayoutStore,
hasHydratedAgents,
hasLoadedTerminals: terminalsQuery.isSuccess,
activeAgentCount: workspaceAgentVisibility.activeAgentIds.size,
terminalCount: terminals.length,
// Workspace-wide, like every other count here. A pane-scoped count made an
// empty pane look like an empty workspace and reseeded a draft on top of it.
tabCount: uiTabs.length,
})
) {
emptyWorkspaceSeedRef.current = null;
return;
}
const workspaceKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
if (emptyWorkspaceSeedRef.current === workspaceKey) {
return;
}
emptyWorkspaceSeedRef.current = workspaceKey;
openWorkspaceDraftTab({ ambient: true });
}, [
normalizedServerId,
normalizedWorkspaceId,
openWorkspaceDraftTab,
persistenceKey,
hasHydratedAgents,
hasHydratedWorkspaceLayoutStore,
isRouteFocused,
terminals.length,
terminalsQuery.isSuccess,
uiTabs.length,
workspaceDirectory,
workspaceAgentVisibility.activeAgentIds.size,
]);
const handleOpenFileFromChat = useCallback(
(location: WorkspaceFileLocation, parentTabId?: string | null) => {
const normalizedLocation = normalizeWorkspaceFileLocation(location);
@@ -2212,6 +2208,7 @@ function WorkspaceScreenContent({
}, [uiTabs]);
const bulkCloseConfirmationLabels = useMemo<BulkCloseConfirmationLabels>(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
all: ({ agents, terminals: terminalCount, tabs: tabCount }) =>
t("workspace.tabs.confirmations.bulk.all", {
agents,
@@ -2244,6 +2241,7 @@ function WorkspaceScreenContent({
const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]);
const tabFallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"),
@@ -2274,21 +2272,15 @@ function WorkspaceScreenContent({
[openWorkspaceDraftTab],
);
const handleCreateTerminal = useStableEvent(createTerminal);
const handleOpenPaneView = useCallback(
(input: { paneId: string; target: WorkspaceTabTarget }) => {
if (!persistenceKey) {
return;
}
createWorkspaceTab(persistenceKey, input.target, paneLocalPlacement(input.paneId));
},
[createWorkspaceTab, persistenceKey],
);
const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
createTerminal({
destination: input?.paneId ? { kind: "open", paneId: input.paneId } : { kind: "open" },
});
});
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfile) => {
createTerminal({ profile });
createTerminal({ profile, destination: { kind: "open" } });
},
[createTerminal],
);
@@ -2308,6 +2300,45 @@ function WorkspaceScreenContent({
[openWorkspaceTabFocused, persistenceKey],
);
const handleCreateNewTab = useCallback(
(input?: { paneId?: string }) => {
if (!persistenceKey) {
return;
}
createWorkspaceTab(persistenceKey, { kind: "new_tab" }, paneLocalPlacement(input?.paneId));
},
[createWorkspaceTab, persistenceKey],
);
const activateNewTab = useCallback(
(tabId: string, selection: NewTabSelection) => {
if (!persistenceKey) {
return;
}
if (selection.kind === "target") {
replaceWorkspaceTabTarget(persistenceKey, tabId, selection.target);
return;
}
if (selection.kind === "agent") {
replaceWorkspaceTabTarget(persistenceKey, tabId, {
kind: "draft",
draftId: generateDraftId(),
});
return;
}
if (selection.kind === "terminal") {
createTerminal({
profile: selection.profile,
destination: { kind: "replace", tabId },
});
return;
}
const { browserId } = createWorkspaceBrowser();
replaceWorkspaceTabTarget(persistenceKey, tabId, { kind: "browser", browserId });
},
[createTerminal, persistenceKey, replaceWorkspaceTabTarget],
);
const handleOpenUrlInBrowserTab = useCallback(
(url: string) => {
if (!persistenceKey || !getIsElectron()) {
@@ -2972,6 +3003,9 @@ function WorkspaceScreenContent({
case "workspace.browser.new":
handleCreateBrowserTab();
return true;
case "workspace.tab.menu.open":
handleCreateNewTab({ paneId: focusedPaneTabState.pane?.id });
return true;
case "workspace.tab.close-current":
if (activeTabId) {
void handleCloseTabById(activeTabId);
@@ -3005,12 +3039,55 @@ function WorkspaceScreenContent({
handleCloseTabById,
handleCreateDraftTab,
handleCreateBrowserTab,
handleCreateNewTab,
handleCreateTerminal,
focusedPaneTabState.pane?.id,
navigateToTabId,
tabs,
],
);
const handleWorkspaceDirectTargetAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
const paneId = focusedPaneTabState.pane?.id;
switch (action.id) {
case "workspace.tab.target.agent":
handleCreateDraftTab({ paneId });
return true;
case "workspace.tab.target.browser":
handleCreateBrowserTab({ paneId });
return true;
case "workspace.tab.target.changes":
if (persistenceKey && isGitCheckout) {
openSupportingTab({
isCompact: isMobile,
workspaceKey: persistenceKey,
target: { kind: "working_diff" },
openInSidePanelByDefault,
});
}
return true;
case "workspace.tab.target.files":
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "files" }, paneLocalPlacement(paneId));
}
return true;
default:
return false;
}
},
[
focusedPaneTabState.pane?.id,
handleCreateBrowserTab,
handleCreateDraftTab,
isGitCheckout,
isMobile,
openInSidePanelByDefault,
openWorkspaceTabFocused,
persistenceKey,
],
);
const handleWorkspaceSidebarAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id === "sidebar.toggle.right") {
@@ -3134,6 +3211,7 @@ function WorkspaceScreenContent({
"workspace.tab.navigate-relative",
"workspace.terminal.new",
"workspace.browser.new",
"workspace.tab.menu.open",
] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
@@ -3141,6 +3219,24 @@ function WorkspaceScreenContent({
handle: handleWorkspaceTabAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-direct-target-actions",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
actions: [
"workspace.tab.target.agent",
"workspace.tab.target.browser",
"workspace.tab.target.changes",
"workspace.tab.target.files",
] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceDirectTargetAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-panel-open-actions",
@@ -3364,6 +3460,7 @@ function WorkspaceScreenContent({
isMissingWorkspaceDirectory,
activeTabDescriptor,
hasHydratedAgents,
hasLoadedTerminals: terminalsQuery.isSuccess,
mountedFocusedPaneTabIds,
focusedPaneTabDescriptorMap,
isRouteFocused,
@@ -3457,42 +3554,6 @@ function WorkspaceScreenContent({
[focusedPaneId, handleReorderTabsInPane],
);
const renderSplitPaneEmptyState = useCallback(
function renderSplitPaneEmptyState(input: {
paneId: string;
paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void;
}) {
// Same predicate the close action reads, so the button is offered exactly when
// pressing it would do something.
const canClosePane =
!input.paneIsolated &&
Boolean(workspaceLayout) &&
canDismissPaneInLayout(workspaceLayout as WorkspaceLayout, input.paneId);
return (
<WorkspacePaneEmptyState
paneId={input.paneId}
isFocused={input.isFocused}
showChanges={isGitCheckout}
showPullRequest={hasPullRequest}
onOpenView={handleOpenPaneView}
onCreateAgent={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onClosePane={canClosePane ? input.onClosePane : null}
/>
);
},
[
handleCreateDraftTab,
handleCreateTerminal,
handleOpenPaneView,
hasPullRequest,
isGitCheckout,
workspaceLayout,
],
);
const containerStyle = [styles.container, styles.containerWorkspaceBackground];
const workspaceScreenGate = renderWorkspaceRouteGate({
@@ -3604,6 +3665,16 @@ function WorkspaceScreenContent({
[createTerminalMutation.isPending, pendingTerminalCreateInput],
);
const showCreateBrowserTab = getIsElectron();
const newTabLauncher = useMemo<NewTabLauncher>(
() => ({
showChanges: isGitCheckout,
showPullRequest: hasPullRequest,
showBrowser: showCreateBrowserTab,
terminalDisabled: createTerminalDisabled,
activate: activateNewTab,
}),
[activateNewTab, createTerminalDisabled, hasPullRequest, isGitCheckout, showCreateBrowserTab],
);
const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]);
const desktopFocusModeEnabled = useMemo(
() => isFocusModeEnabled && !isMobile,
@@ -3642,19 +3713,14 @@ function WorkspaceScreenContent({
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
onCloseTabsToRight={handleCloseTabsToRightInPane}
onCloseOtherTabs={handleCloseOtherTabsInPane}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onCreateNewTab={handleCreateNewTab}
buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane}
onSplitPane={handleSplitPane}
onSplitPaneEmpty={handleCreateEmptySplit}
onMoveTabToPane={handleMoveTabToPane}
onClosePane={handleClosePane}
onResizeSplit={handleResizePaneSplit}
onReorderTabsInPane={handleReorderTabsInPane}
renderPaneEmptyState={renderSplitPaneEmptyState}
/>
);
}, [
@@ -3680,22 +3746,24 @@ function WorkspaceScreenContent({
handleCloseTabsToLeftInPane,
handleCloseTabsToRightInPane,
handleCloseOtherTabsInPane,
handleCreateDraftTab,
handleCreateTerminal,
handleCreateBrowserTab,
showCreateBrowserTab,
handleCreateNewTab,
buildDesktopPaneContentModel,
handleFocusPane,
handleSplitPane,
handleCreateEmptySplit,
handleMoveTabToPane,
handleClosePane,
handleResizePaneSplit,
handleReorderTabsInPane,
renderSplitPaneEmptyState,
]);
const desktopContent = desktopSplitContent ?? content;
const workspacePanelContent = (
<WorkspacePanelContent
launcher={newTabLauncher}
content={isMobile ? content : desktopContent}
/>
);
const workspaceCenterColumn = (
<View style={styles.centerColumn}>
{showScreenHeader && (
@@ -3779,63 +3847,57 @@ function WorkspaceScreenContent({
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onCreateNewTab={handleCreateNewTab}
onReorderTabs={handleReorderTabsInFocusedPane}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<View style={styles.content}>{content}</View>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
</View>
<View style={styles.centerContent}>{workspacePanelContent}</View>
</View>
);
const renderedWorkspaceScreen = (
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
</View>
<ImportSessionSheet
visible={isRouteFocused && isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
workspaceId={normalizedWorkspaceId}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={isRouteFocused ? renamingTab : null}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</RenderProfile>
);
if (gatedWorkspaceScreen) {
return gatedWorkspaceScreen;
}
return (
gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
</View>
<ImportSessionSheet
visible={isRouteFocused && isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
workspaceId={normalizedWorkspaceId}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={isRouteFocused ? renamingTab : null}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</RenderProfile>
</WorkspaceFocusProvider>
)
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
{renderedWorkspaceScreen}
</WorkspaceFocusProvider>
);
}
@@ -157,6 +157,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "plugin") {
return `workspace-plugin-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
}
if (tab.target.kind === "new_tab") {
return `workspace-new-tab-close-${tab.tabId}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}
@@ -321,7 +324,7 @@ export function buildWorkspaceTabMenuEntries(
export function buildWorkspaceDesktopTabActions(
input: BuildWorkspaceDesktopTabActionsInput,
): WorkspaceDesktopTabActions {
const contextMenuTestId = `workspace-tab-context-${buildDeterministicWorkspaceTabId(input.tab.target)}`;
const contextMenuTestId = `workspace-tab-context-${input.tab.tabId}`;
return {
contextMenuTestId,
menuEntries: buildWorkspaceTabMenuEntries({
@@ -3,7 +3,6 @@ import {
deriveWorkspacePaneState,
type WorkspaceDerivedTab,
} from "@/screens/workspace/workspace-pane-state";
import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity";
export interface WorkspaceTabModel {
tabs: WorkspaceDerivedTab[];
@@ -11,10 +10,6 @@ export interface WorkspaceTabModel {
activeTab: WorkspaceDerivedTab | null;
}
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
return buildDeterministicWorkspaceTabId(target);
}
export function deriveWorkspaceTabModel(input: {
tabs: WorkspaceTab[];
focusedTabId?: string | null;
@@ -10,6 +10,8 @@ import {
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import { createNewWorkspaceTab } from "@/workspace-tabs/new-tab";
import { generateDraftId } from "@/stores/draft-keys";
export interface SplitPane {
id: string;
@@ -309,6 +311,22 @@ function createPaneNode(input: {
};
}
function ensureRetainedPaneHasTab(pane: SplitPaneInternal): SplitPaneInternal {
if (pane.tabs.length > 0) {
return pane;
}
const newTab = createNewWorkspaceTab();
return normalizePaneAfterTabChange({
...pane,
tabs: [newTab],
focusedTabId: newTab.tabId,
});
}
function isSoleNewTabPane(pane: SplitPaneInternal): boolean {
return pane.tabs.length === 1 && pane.tabs[0]?.target.kind === "new_tab";
}
function createGroupNode(input: {
id: string;
direction: "horizontal" | "vertical";
@@ -339,7 +357,11 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
if (!target) {
return null;
}
const tabId = trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target);
const persistedTabId = trimNonEmpty(tab.tabId);
if (!persistedTabId && target.kind === "new_tab") {
return null;
}
const tabId = persistedTabId ?? buildDeterministicWorkspaceTabId(target);
if (!tabId) {
return null;
}
@@ -730,7 +752,7 @@ function reorderTabsForPane(input: ReorderTabsForPaneInput): SplitPaneInternal {
function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInternal {
if (path.length === 0) {
invariant(root.kind === "pane", "Expected pane at root while removing pane");
return createPaneNode({ id: root.pane.id });
return createPaneNode({ id: root.pane.id, tabs: [createNewWorkspaceTab()] });
}
const parentPath = path.slice(0, -1);
@@ -777,7 +799,17 @@ function detachTabFromTree(
const nextRoot = replaceNodeAtPath(root, panePath, () => ({ kind: "pane", pane: nextPane }));
if (nextPane.tabs.length > 0 || nextPane.id === input.preserveEmptyPaneId) {
return { root: nextRoot, tab, sourcePaneId: paneNode.pane.id };
return {
root:
nextPane.tabs.length > 0
? nextRoot
: replaceNodeAtPath(nextRoot, panePath, () => ({
kind: "pane",
pane: ensureRetainedPaneHasTab(nextPane),
})),
tab,
sourcePaneId: paneNode.pane.id,
};
}
return {
@@ -796,10 +828,14 @@ function insertTabIntoPane(
return replaceNodeAtPath(root, panePath, (node) => {
invariant(node.kind === "pane", "Expected pane while inserting tab");
const existingIndex = node.pane.tabs.findIndex((tab) => tab.tabId === input.tab.tabId);
const nextTabs =
existingIndex >= 0
? node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab))
: [...node.pane.tabs, input.tab];
let nextTabs: WorkspaceTab[];
if (input.tab.target.kind !== "new_tab" && isSoleNewTabPane(node.pane)) {
nextTabs = [input.tab];
} else if (existingIndex >= 0) {
nextTabs = node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab));
} else {
nextTabs = [...node.pane.tabs, input.tab];
}
return {
kind: "pane",
pane: normalizePaneAfterTabChange({
@@ -1045,9 +1081,7 @@ export function collectAllPanes(root: SplitNode): SplitPane[] {
}
function isEphemeralTab(tab: WorkspaceTab): boolean {
// Commit diff tabs are ephemeral: their SHA may be rebased away before the next
// load, so a restored tab could point at a dead commit.
return tab.target.kind === "commit_diff";
return tab.target.kind === "commit_diff" || tab.target.kind === "new_tab";
}
function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal {
@@ -1074,12 +1108,8 @@ function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal
}
/**
* Returns a copy of `layout` with every ephemeral tab (commit diff tabs) removed
* from each pane. Applied in the layout store's `partialize` so commit diff tabs
* are never written to storage — they're dropped on the next reload rather than
* restored pointing at a possibly-rebased SHA. Working diff tabs and all other
* tab kinds are left intact. Panes (and their ids/structure) are preserved even
* when emptied; the parent-tab map is renormalized against the surviving tabs.
* Returns a copy of `layout` with ephemeral tabs removed from each pane. Panes and
* their structure remain so deserialization can restore empty panes explicitly.
*/
export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): WorkspaceLayout {
const internalLayout = asInternalLayout(layout);
@@ -1091,6 +1121,32 @@ export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): Workspace
});
}
function restoreEmptyPanesInNode(node: SplitNodeInternal): SplitNodeInternal {
if (node.kind === "pane") {
return node.pane.tabs.length > 0
? node
: createPaneNode({
id: node.pane.id,
tabs: [createNewWorkspaceTab()],
hidden: node.pane.hidden,
});
}
return createGroupNode({
id: node.group.id,
direction: node.group.direction,
children: node.group.children.map(restoreEmptyPanesInNode),
sizes: node.group.sizes,
});
}
export function restoreEmptyPanesInLayout(layout: WorkspaceLayout): WorkspaceLayout {
const normalized = normalizeLayout(layout);
return {
...normalized,
root: restoreEmptyPanesInNode(asInternalNode(normalized.root)),
};
}
export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): string | null {
if (!layout) {
return null;
@@ -1107,7 +1163,7 @@ export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined):
export function createDefaultLayout(): WorkspaceLayout {
return {
root: createPaneNode({ id: DEFAULT_PANE_ID }),
root: createPaneNode({ id: DEFAULT_PANE_ID, tabs: [createNewWorkspaceTab()] }),
focusedPaneId: DEFAULT_PANE_ID,
};
}
@@ -1119,8 +1175,12 @@ export function createWorkspaceLayoutWithSidePanel(): WorkspaceLayout {
id: DEFAULT_LAYOUT_GROUP_ID,
direction: "horizontal",
children: [
createPaneNode({ id: DEFAULT_PANE_ID }),
createPaneNode({ id: SIDE_PANEL_PANE_ID, hidden: true }),
createPaneNode({ id: DEFAULT_PANE_ID, tabs: [createNewWorkspaceTab()] }),
createPaneNode({
id: SIDE_PANEL_PANE_ID,
tabs: [createNewWorkspaceTab()],
hidden: true,
}),
],
sizes: [0.5, 0.5],
}),
@@ -1176,17 +1236,17 @@ const SIDE_PANEL_EXCLUDED_TAB_KINDS: ReadonlySet<WorkspaceTabTarget["kind"]> = n
]);
function resolvePlacementPane(input: {
layout: { root: SplitNode; focusedPaneId: string | null };
layout: { root: SplitNodeInternal; focusedPaneId: string | null };
target: WorkspaceTabTarget;
placement: WorkspaceTabPlacement;
sidePanelPaneId: string | null;
}): SplitPane {
}): SplitPaneInternal {
const requestedPane =
input.placement.mode === "pane" || input.placement.mode === "prefer"
? findPaneById(input.layout.root, input.placement.paneId)
: null;
if (requestedPane) {
return requestedPane;
return requestedPane as SplitPaneInternal;
}
// `collectAllPanes` skips hidden panes, so a focused-but-hidden pane — the side
@@ -1202,18 +1262,16 @@ function resolvePlacementPane(input: {
focusedPane.id !== input.sidePanelPaneId ||
!SIDE_PANEL_EXCLUDED_TAB_KINDS.has(input.target.kind)
) {
return focusedPane;
return focusedPane as SplitPaneInternal;
}
// `collectAllPanes` skips hidden panes, so the chain falls back to the side panel
// pane itself only when it is the sole visible pane. The side panel is the only
// pane anything ever hides, so today that means "the side panel is the only pane".
const panes = collectAllPanes(input.layout.root);
return (
panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ??
return (panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ??
panes.find((pane) => pane.id !== focusedPane.id) ??
focusedPane
);
focusedPane) as SplitPaneInternal;
}
function insertNewTabIntoPane(
@@ -1235,6 +1293,23 @@ function insertNewTabIntoPane(
...(input.state !== undefined ? { state: input.state } : {}),
};
const currentTab = isSoleNewTabPane(targetPane) ? targetPane.tabs[0] : null;
if (currentTab && input.target.kind !== "new_tab") {
return {
tabId,
layout: withNormalizedParentTabMap({
root: replaceTabInTree(layout.root, {
tabId: currentTab.tabId,
nextTabId: tabId,
target: input.target,
state: input.state,
}),
focusedPaneId: input.focus ? targetPane.id : layout.focusedPaneId,
parentTabIdByTabId: input.layout.parentTabIdByTabId,
}),
};
}
const preservedFocusTabId = targetPane.focusedTabId ?? tabId;
return {
@@ -1252,6 +1327,9 @@ function insertNewTabIntoPane(
}
function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) {
if (target.kind === "new_tab") {
return null;
}
const registration = getPanelRegistration(target.kind);
const targetResourceKey = registration?.resourceKey(target as never);
const targetIdentity = targetResourceKey ?? buildDeterministicWorkspaceTabId(target);
@@ -1585,6 +1663,7 @@ export function replaceTabTargetInLayout(
// File navigation is a retarget of one File tab, not a new panel instance. Keeping its
// identity preserves the mounted tab track and the File tab's local presentation state.
if (
currentTab?.target.kind === "new_tab" ||
currentTab?.target.kind === "draft" ||
(currentTab?.target.kind === "file" && input.target.kind === "file")
) {
@@ -1775,7 +1854,7 @@ export function splitPaneEmptyInLayout(
invariant(targetNode.kind === "pane", "Expected target pane");
const newPaneId = input.createNodeId("pane");
const newPaneNode = createPaneNode({ id: newPaneId });
const newPaneNode = createPaneNode({ id: newPaneId, tabs: [createNewWorkspaceTab()] });
const parentPath = targetPath.slice(0, -1);
const targetIndex = targetPath[targetPath.length - 1] ?? 0;
@@ -1823,6 +1902,17 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp
return null;
}
const sourceTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId);
const isMovingSoleNewTab =
sourcePane.id !== input.toPaneId &&
sourcePane.tabIds.length === 1 &&
sourceTab?.target.kind === "new_tab";
if (isMovingSoleNewTab) {
return sourcePane.id === SIDE_PANEL_PANE_ID
? setPaneHiddenInLayout({ layout, paneId: sourcePane.id, hidden: true })
: closePaneInLayout({ layout, paneId: sourcePane.id });
}
const detached = detachTabFromTree(layout.root, {
tabId: input.tabId,
// Dragging a pane's last tab elsewhere collapses that pane — except the side
@@ -2199,6 +2289,36 @@ function addMissingEntityTabs(input: {
return nextLayout;
}
function seedDraftForEmptyWorkspace(input: {
layout: WorkspaceLayout;
snapshot: WorkspaceTabSnapshot;
activeAgentIds: Set<string>;
knownTerminalIds: Set<string>;
sidePanelPaneId: 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 hasContentTab = collectAllTabs(input.layout.root).some(
(tab) => tab.target.kind !== "new_tab",
);
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,
sidePanelPaneId: input.sidePanelPaneId,
createTabId: () => draftId,
}).layout;
}
export function reconcileWorkspaceTabs(
state: WorkspaceTabReconcileState,
snapshot: WorkspaceTabSnapshot,
@@ -2287,6 +2407,14 @@ export function reconcileWorkspaceTabs(
sidePanelPaneId: state.sidePanelPaneId,
});
nextLayout = seedDraftForEmptyWorkspace({
layout: nextLayout,
snapshot,
activeAgentIds,
knownTerminalIds,
sidePanelPaneId: state.sidePanelPaneId,
});
if (reconciledFocusedTabId) {
nextLayout =
focusTabInLayout({
@@ -118,12 +118,52 @@ function collectTabIds(root: SplitNode): string[] {
return collectAllTabs(root).map((tab) => tab.tabId);
}
function collectContentTabs(root: SplitNode): WorkspaceTab[] {
return collectAllTabs(root).filter((tab) => tab.target.kind !== "new_tab");
}
function contentTabs(tabs: WorkspaceTab[]): WorkspaceTab[] {
return tabs.filter((tab) => tab.target.kind !== "new_tab");
}
function expectGroup(node: SplitNode): Extract<SplitNode, { kind: "group" }> {
expect(node.kind).toBe("group");
return node as Extract<SplitNode, { kind: "group" }>;
}
describe("workspace-layout-store helpers", () => {
it("creates independent random New tabs for the default panes", () => {
const layout = createWorkspaceLayoutWithSidePanel();
const tabs = collectAllTabs(layout.root);
expect(tabs).toHaveLength(2);
expect(tabs.map((tab) => tab.target)).toEqual([{ kind: "new_tab" }, { kind: "new_tab" }]);
expect(new Set(tabs.map((tab) => tab.tabId)).size).toBe(2);
});
it("discards persisted New tabs and restores each empty pane with a fresh identity", async () => {
const persisted = createWorkspaceLayoutWithSidePanel();
const persistedIds = new Set(collectAllTabs(persisted.root).map((tab) => tab.tabId));
await AsyncStorage.setItem(
"workspace-layout-state",
JSON.stringify({
state: {
layoutByWorkspace: { workspace: persisted },
splitSizesByWorkspace: {},
explorerPaneIdByWorkspace: { workspace: "explorer" },
},
version: 1,
}),
);
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
const restoredTabs = collectAllTabs(restored.getState().layoutByWorkspace.workspace.root);
expect(restoredTabs).toHaveLength(2);
expect(new Set(restoredTabs.map((tab) => tab.tabId)).size).toBe(2);
expect(restoredTabs.every((tab) => !persistedIds.has(tab.tabId))).toBe(true);
});
it("finds panes and tabs across nested groups", () => {
const root: SplitNode = {
kind: "group",
@@ -400,7 +440,8 @@ describe("workspace-layout-store tree transforms", () => {
const singlePaneRoot = createPane({ id: "main", tabIds: ["tab-a"] });
const emptied = removeTabFromTree(singlePaneRoot, "tab-a");
expect(emptied).toEqual(createPane({ id: "main", tabIds: [], focusedTabId: null }));
expect(collectAllTabs(emptied)).toHaveLength(1);
expect(collectAllTabs(emptied)[0]?.target).toEqual({ kind: "new_tab" });
});
});
@@ -701,6 +742,74 @@ describe("workspace-layout-store actions", () => {
});
});
it("replaces a pane's sole New tab when real content opens", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const initialLayout = createWorkspaceLayoutWithSidePanel();
workspaceLayoutStore.setState({ layoutByWorkspace: { [workspaceKey]: initialLayout } });
const initialMainTabId = findPaneById(initialLayout.root, "main")?.focusedTabId;
const agentTabId = store.openTab({
workspaceKey,
target: { kind: "agent", agentId: "agent-1" },
intent: "background",
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([agentTabId]);
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(agentTabId);
expect(collectTabIds(layout.root)).not.toContain(initialMainTabId);
});
it("keeps a New tab's random identity when its launcher selects content", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const initialLayout = createWorkspaceLayoutWithSidePanel();
workspaceLayoutStore.setState({ layoutByWorkspace: { [workspaceKey]: initialLayout } });
const newTabId = findPaneById(initialLayout.root, "main")?.focusedTabId as string;
const resultTabId = store.replaceTab(workspaceKey, newTabId, { kind: "files" });
expect(resultTabId).toBe(newTabId);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([newTabId]);
expect(collectAllTabs(layout.root).find((tab) => tab.tabId === newTabId)?.target).toEqual({
kind: "files",
});
});
it("closes a split through its sole New tab but keeps the final visible split", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTab({
workspaceKey,
target: { kind: "agent", agentId: "agent-1" },
intent: "reveal",
});
useWorkspaceLayoutIds("new-pane", "new-group");
const splitPaneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
}) as string;
const splitNewTabId = findPaneById(
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
splitPaneId,
)?.focusedTabId as string;
store.closeTab(workspaceKey, splitNewTabId);
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, splitPaneId)).toBeNull();
store.closeTab(workspaceKey, findPaneById(layout.root, "main")?.focusedTabId as string);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const finalNewTabId = findPaneById(layout.root, "main")?.focusedTabId as string;
const before = layout;
store.closeTab(workspaceKey, finalNewTabId);
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before);
});
it("migrates legacy layouts to include the hidden registered explorer pane", async () => {
const legacyLayout = createDefaultLayout();
await AsyncStorage.setItem(
@@ -1358,7 +1467,7 @@ describe("workspace-layout-store actions", () => {
await restored.persist.rehydrate();
const layout = restored.getState().layoutByWorkspace.workspace;
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
expect(layout && collectContentTabs(layout.root).map((tab) => tab.target)).toEqual([
workspaceTarget,
agentTarget,
]);
@@ -1408,7 +1517,7 @@ describe("workspace-layout-store actions", () => {
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
expect(duplicateTabId).toBe(secondTabId);
expect(layout.focusedPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([
"file_/repo/worktree/a.ts",
"file_/repo/worktree/b.ts",
]);
@@ -1441,7 +1550,7 @@ describe("workspace-layout-store actions", () => {
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
expect(secondTabId).toBe(firstTabId);
expect(collectAllTabs(layout.root)).toEqual([
expect(collectContentTabs(layout.root)).toEqual([
{
tabId: "file_/repo/worktree/a.ts",
target: {
@@ -1701,7 +1810,7 @@ describe("workspace-layout-store actions", () => {
expect(secondTabId).toBe("draft-2");
expect(firstTabId).not.toBe(secondTabId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]);
expect(collectAllTabs(layout.root)).toEqual([
expect(collectContentTabs(layout.root)).toEqual([
{
tabId: firstTabId,
target: { kind: "draft", draftId: "draft-1" },
@@ -1925,7 +2034,7 @@ describe("workspace-layout-store actions", () => {
expect(draftTabId).toBe("draft-retarget");
expect(nextTabId).toBe(draftTabId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([draftTabId!]);
expect(collectAllTabs(layout.root)).toEqual([
expect(collectContentTabs(layout.root)).toEqual([
{
tabId: draftTabId!,
target: { kind: "file", path: "/repo/worktree/retargeted.ts" },
@@ -1955,7 +2064,7 @@ describe("workspace-layout-store actions", () => {
expect(agentTabId).toBe("agent_agent-retarget");
expect(nextTabId).toBe("draft-from-agent");
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["draft-from-agent"]);
expect(collectAllTabs(layout.root)).toEqual([
expect(collectContentTabs(layout.root)).toEqual([
{
tabId: "draft-from-agent",
target: { kind: "draft", draftId: "draft-from-agent" },
@@ -2006,7 +2115,7 @@ describe("workspace-layout-store actions", () => {
expect(draftTabId).toBe("draft-dup");
expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555");
expect(nextTabId).not.toBe(existingFileTabId);
expect(collectAllTabs(layout.root)).toHaveLength(3);
expect(collectContentTabs(layout.root)).toHaveLength(3);
expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "file")).toHaveLength(2);
});
@@ -2043,7 +2152,7 @@ describe("workspace-layout-store actions", () => {
expect(firstAgentTabId).toBe(firstDraftTabId);
expect(nextTabId).not.toBe(firstDraftTabId);
expect(collectAllTabs(layout.root)).toHaveLength(2);
expect(collectContentTabs(layout.root)).toHaveLength(2);
expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "agent")).toHaveLength(
2,
);
@@ -2369,6 +2478,49 @@ describe("workspace-layout-store actions", () => {
]);
});
it("moving a pane's sole New tab closes that pane instead of moving the tab", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const fileTabId = store.openTab({
workspaceKey,
target: { kind: "file", path: "/repo/worktree/a.ts" },
intent: "reveal",
}) as string;
const splitPaneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
}) as string;
const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const newTabId = findPaneById(before.root, splitPaneId)?.focusedTabId as string;
store.moveTabToPane(workspaceKey, newTabId, "main");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([fileTabId]);
expect(collectAllTabs(layout.root).some((tab) => tab.tabId === newTabId)).toBe(false);
});
it("moving the Side panel's sole New tab hides the panel instead of moving the tab", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
const fileTabId = store.openTab({
workspaceKey,
target: { kind: "file", path: "/repo/worktree/a.ts" },
intent: "reveal",
}) as string;
const sidePanelPaneId = store.showSidePanel(workspaceKey) as string;
const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const newTabId = findPaneById(before.root, sidePanelPaneId)?.focusedTabId as string;
store.moveTabToPane(workspaceKey, newTabId, "main");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, sidePanelPaneId)?.hidden).toBe(true);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([fileTabId]);
expect(findPaneById(layout.root, sidePanelPaneId)?.tabIds).toEqual([newTabId]);
});
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
useWorkspaceLayoutIds(
"78787878-7878-7878-7878-787878787878",
@@ -2460,7 +2612,7 @@ describe("workspace-layout-store actions", () => {
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
expect(duplicateTabId).toBe(secondTabId);
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([
"file_/repo/worktree/a.ts",
"file_/repo/worktree/b.ts",
]);
@@ -2621,7 +2773,7 @@ describe("workspace-layout-store actions", () => {
expect(total).toBeCloseTo(1, 10);
});
it("closing the last tab keeps a single empty pane in the layout", () => {
it("closing the last content tab creates a fresh New tab in the retained pane", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
@@ -2633,7 +2785,12 @@ describe("workspace-layout-store actions", () => {
store.closeTab(workspaceKey, tabId!);
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(layout).toEqual(createWorkspaceLayoutWithSidePanel());
const mainTab = collectAllTabs(layout.root).find(
(tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main",
);
expect(mainTab?.target).toEqual({ kind: "new_tab" });
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true);
});
it("keeps pinned archived agents in memory per workspace without persisting them", () => {
@@ -2739,7 +2896,7 @@ describe("workspace-layout-store actions", () => {
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
expect(nextTabId).toBe("agent_agent-1");
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
});
@@ -2858,7 +3015,7 @@ describe("workspace-layout-store actions", () => {
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(collectAllTabs(layout.root)).toEqual([
expect(collectContentTabs(layout.root)).toEqual([
{
tabId: "draft-agent",
target: { kind: "agent", agentId: "agent-1" },
@@ -2888,7 +3045,47 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]);
});
it("reconcileTabs lands on an existing agent instead of the initial New tab", () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["agent_agent-1"]);
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: [],
knownAgentIds: [],
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 does not auto-open subagents omitted from autoOpenAgentIds", () => {
@@ -2905,10 +3102,9 @@ describe("workspace-layout-store actions", () => {
});
expect(
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map(
(tab) => tab.tabId,
),
).toEqual(["agent_parent-agent"]);
});
@@ -2933,10 +3129,9 @@ describe("workspace-layout-store actions", () => {
});
expect(
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map(
(tab) => tab.tabId,
),
).toEqual(["agent_child-agent", "agent_parent-agent"]);
});
@@ -2961,10 +3156,9 @@ describe("workspace-layout-store actions", () => {
});
expect(
workspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.map((tab) => tab.tabId),
contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map(
(tab) => tab.tabId,
),
).toEqual(["agent_parent-agent"]);
});
@@ -2983,7 +3177,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]);
store.openTab({
workspaceKey: workspaceKey,
@@ -2993,7 +3187,7 @@ describe("workspace-layout-store actions", () => {
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toEqual([
"agent_child-agent",
]);
});
@@ -3022,7 +3216,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const tabs = contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey));
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
@@ -3042,7 +3236,7 @@ describe("workspace-layout-store actions", () => {
hasActivePendingDraftCreate: false,
});
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]);
});
it("explicitly opening an agent tab clears hidden intent", () => {
@@ -3058,7 +3252,9 @@ describe("workspace-layout-store actions", () => {
const state = workspaceLayoutStore.getState();
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toEqual([
"agent_agent-1",
]);
});
it("pinning an agent clears hidden intent", () => {
@@ -3094,9 +3290,12 @@ describe("workspace-layout-store actions", () => {
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_archived-agent",
]);
expect(
store
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.tabId),
).toEqual(["agent_archived-agent"]);
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
@@ -3107,9 +3306,12 @@ describe("workspace-layout-store actions", () => {
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_archived-agent",
]);
expect(
store
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.tabId),
).toEqual(["agent_archived-agent"]);
store.resolvePendingAgent(workspaceKey, "archived-agent");
store.reconcileTabs(workspaceKey, {
@@ -3121,7 +3323,12 @@ describe("workspace-layout-store actions", () => {
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([]);
expect(
store
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.tabId),
).toEqual([]);
store.openTab({
workspaceKey: workspaceKey,
@@ -3138,9 +3345,12 @@ describe("workspace-layout-store actions", () => {
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
"agent_archived-agent",
]);
expect(
store
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.tabId),
).toEqual(["agent_archived-agent"]);
});
it("retargeting a tab to an agent clears hidden intent", () => {
@@ -3218,7 +3428,7 @@ describe("workspace-layout-store actions", () => {
store.closePane(workspaceKey, "main");
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([keptTabId]);
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([keptTabId]);
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId]);
});
@@ -3265,7 +3475,7 @@ describe("workspace-layout-store actions", () => {
).toBeUndefined();
});
it("closing the final Side panel tab hides its empty pane until it is revealed again", () => {
it("closing the final Side panel content tab leaves New tab hidden until reveal", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTab({
@@ -3284,12 +3494,17 @@ describe("workspace-layout-store actions", () => {
store.closeTab(workspaceKey, filesTabId);
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [], hidden: true });
const hiddenPane = findPaneById(layout.root, paneId);
expect(hiddenPane?.hidden).toBe(true);
expect(hiddenPane?.tabIds).toHaveLength(1);
expect(
collectAllTabs(layout.root).find((tab) => tab.tabId === hiddenPane?.focusedTabId)?.target,
).toEqual({ kind: "new_tab" });
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
expect(store.showSidePanel(workspaceKey)).toBe(paneId);
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] });
expect(findPaneById(layout.root, paneId)?.tabIds).toEqual(hiddenPane?.tabIds);
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
});
@@ -3322,7 +3537,7 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, paneId)?.tabIds).toEqual(["working_diff"]);
});
it("closing the final Side panel tab keeps the final visible pane on screen", () => {
it("closing the final Side panel content tab keeps New tab on screen", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTab({
@@ -3343,7 +3558,11 @@ describe("workspace-layout-store actions", () => {
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([paneId]);
expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] });
const finalPane = findPaneById(layout.root, paneId);
expect(finalPane?.tabIds).toHaveLength(1);
expect(
collectAllTabs(layout.root).find((tab) => tab.tabId === finalPane?.focusedTabId)?.target,
).toEqual({ kind: "new_tab" });
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
});
@@ -3407,7 +3626,7 @@ describe("workspace-layout-store actions", () => {
expect(findPaneContainingTab(layout.root, fileTabId)?.id).toBe("main");
expect(layout.focusedPaneId).toBe("main");
});
it("leaves the side panel behind, empty, when its last tab is claimed by another pane", () => {
it("leaves New tab in the Side panel when its content is claimed by another pane", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTab({
@@ -3432,7 +3651,11 @@ describe("workspace-layout-store actions", () => {
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, changesTabId)?.id).toBe("main");
expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] });
const sidePane = findPaneById(layout.root, paneId);
expect(sidePane?.tabIds).toHaveLength(1);
expect(
collectAllTabs(layout.root).find((tab) => tab.tabId === sidePane?.focusedTabId)?.target,
).toEqual({ kind: "new_tab" });
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
});
it("closePane refuses to hide the side panel once it is the only visible pane", () => {
@@ -36,6 +36,7 @@ import {
openTabInLayoutBackground,
replaceTabTargetInLayout,
revealTargetInLayout,
restoreEmptyPanesInLayout,
reconcileWorkspaceTabs,
removePaneFromTree,
removeTabFromTree,
@@ -174,6 +175,7 @@ const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
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(),
@@ -605,6 +607,29 @@ export function createWorkspaceLayoutStore(
state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey],
);
const closingPane = findPaneContainingTab(layout.root, normalizedTabId);
const closingTab = collectAllTabs(layout.root).find(
(tab) => tab.tabId === normalizedTabId,
);
if (closingPane?.tabIds.length === 1 && closingTab?.target.kind === "new_tab") {
const nextLayout =
closingPane.id === sidePanelPaneId
? setPaneHiddenInLayout({
layout,
paneId: closingPane.id,
hidden: true,
})
: closePaneInLayout({ layout, paneId: closingPane.id });
if (!nextLayout) {
return state;
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
};
}
const preserveEmptyPaneId =
closingPane?.id === "main" || closingPane?.id === sidePanelPaneId
? closingPane.id
@@ -1304,13 +1329,16 @@ export function createWorkspaceLayoutStore(
for (const [workspaceKey, persistedLayout] of Object.entries(
result.data.layoutByWorkspace,
)) {
const restoredLayout = restoreEmptyPanesInLayout(
stripEphemeralTabsFromLayout(persistedLayout),
);
const sidePanel = ensurePersistedSidePanelPane({
layout: normalizeLayout(persistedLayout),
layout: restoredLayout,
registeredPaneId: sidePanelPaneIdByWorkspace[workspaceKey],
ids,
});
if (!sidePanel) {
layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout);
layoutByWorkspace[workspaceKey] = restoredLayout;
continue;
}
layoutByWorkspace[workspaceKey] = sidePanel.layout;
@@ -114,10 +114,11 @@ function reconcileWorkspaceTabs(workspaceKey: string, visibility: WorkspaceAgent
);
}
function getWorkspaceTabIds(workspaceKey: string): string[] {
function getWorkspaceContentTabIds(workspaceKey: string): string[] {
return useWorkspaceLayoutStore
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind !== "new_tab")
.map((tab) => tab.tabId);
}
@@ -153,13 +154,13 @@ describe("workspace subagents integration", () => {
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceTabIds(workspaceKey!)).toEqual([]);
expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual([]);
appendAgent(parent);
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
@@ -192,7 +193,7 @@ describe("workspace subagents integration", () => {
initializeAgents([parent, child]);
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
@@ -207,7 +208,10 @@ describe("workspace subagents integration", () => {
appendAgent({ ...child, parentAgentId: null, labels: {} });
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent", "agent_child-agent"]);
expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual([
"agent_parent-agent",
"agent_child-agent",
]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
@@ -241,7 +245,7 @@ describe("workspace subagents integration", () => {
initializeAgents([parent, child]);
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_child-agent"]);
expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual(["agent_child-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
@@ -5,6 +5,18 @@ import {
workspaceTabTargetsEqual,
} from "./identity";
describe("New tab identity", () => {
it("stays outside deterministic target identity", () => {
const target = { kind: "new_tab" } as const;
expect(normalizeWorkspaceTabTarget(target)).toEqual(target);
expect(workspaceTabTargetsEqual(target, target)).toBe(false);
expect(() => buildDeterministicWorkspaceTabId(target)).toThrow(
"New tabs do not have deterministic target identities",
);
});
});
describe("provider subagent tab identity", () => {
test("normalizes and compares the parent and provider child as one tab identity", () => {
const target = normalizeWorkspaceTabTarget({
@@ -15,6 +15,9 @@ export function normalizeWorkspaceTabTarget(
const setup = normalizeWorkspaceDraftTabSetup(value.setup);
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
}
if (value.kind === "new_tab") {
return { kind: "new_tab" };
}
if (value.kind === "agent") {
const agentId = trimNonEmpty(value.agentId);
return agentId ? { kind: "agent", agentId } : null;
@@ -185,6 +188,9 @@ function recordsShallowEqual(
}
export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "new_tab") {
throw new Error("New tabs do not have deterministic target identities");
}
if (target.kind === "draft") {
return target.draftId;
}
+1
View File
@@ -33,6 +33,7 @@ export type PluginWorkspaceTabTarget =
};
export type WorkspaceTabTarget =
| { kind: "new_tab" }
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string }
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
@@ -0,0 +1,17 @@
import { generateMessageId } from "@/types/stream";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
export type NewTabSelection =
| { kind: "target"; target: WorkspaceTab["target"] }
| { kind: "agent" }
| { kind: "terminal"; profile?: TerminalProfile }
| { kind: "browser" };
export function createNewWorkspaceTab(): WorkspaceTab {
return {
tabId: `tab_${generateMessageId()}`,
target: { kind: "new_tab" },
createdAt: Date.now(),
};
}
@@ -76,6 +76,18 @@ beforeEach(() => {
describe("compact surface", () => {
const compact = { isCompact: true, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT };
it("opens Changes in the explorer overlay without creating a workspace tab", () => {
toggleSupportingTab({
...compact,
target: { kind: "working_diff" },
openInSidePanelByDefault: true,
});
expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer");
expect(usePanelStore.getState().explorerTab).toBe("changes");
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined();
});
it("opens and closes the overlay without touching the layout", () => {
openSidePanelView({ ...compact, view: "files" });
@@ -102,11 +114,11 @@ describe("non-compact with splits", () => {
checkout: CHECKOUT,
};
it("reveals an empty pane rather than seeding a view into it", () => {
it("reveals a New tab pane rather than seeding a supporting view into it", () => {
toggleSidePanel(wide);
expect(isSidePanelOpen(wide)).toBe(true);
expect(tabKinds()).toEqual([]);
expect(tabKinds()).toEqual(["new_tab", "new_tab"]);
expect(collectAllPanes(layout().root).length).toBeGreaterThan(1);
});
@@ -204,7 +216,7 @@ describe("non-compact with splits", () => {
hideSidePanel(wide);
expect(isSidePanelOpen(wide)).toBe(false);
expect(tabKinds()).toEqual(["files"]);
expect(tabKinds()).toEqual(["new_tab", "files"]);
});
it("toggles the visible supporting target without closing its tab", () => {
+19 -2
View File
@@ -107,8 +107,10 @@ export interface OpenSupportingTabInput extends SidePanelQuery {
background?: boolean;
}
export interface ToggleSupportingTabInput extends SidePanelQuery {
target: WorkspaceTabTarget;
type SidePanelViewTarget = Extract<WorkspaceTabTarget, { kind: SidePanelTabKind }>;
export interface ToggleSupportingTabInput extends SidePanelInput {
target: SidePanelViewTarget;
openInSidePanelByDefault: boolean;
}
@@ -145,6 +147,10 @@ export function openSupportingTab(input: OpenSupportingTabInput): string | null
/** Toggle a supporting target when it is the visible Side panel tab; otherwise reveal it. */
export function toggleSupportingTab(input: ToggleSupportingTabInput): string | null {
if (input.isCompact) {
openSidePanelView({ ...input, view: explorerViewForTarget(input.target) });
return null;
}
if (!input.workspaceKey) return null;
const store = useWorkspaceLayoutStore.getState();
if (
@@ -158,6 +164,17 @@ export function toggleSupportingTab(input: ToggleSupportingTabInput): string | n
return openSupportingTab(input);
}
function explorerViewForTarget(target: SidePanelViewTarget): ExplorerTab {
switch (target.kind) {
case "working_diff":
return "changes";
case "files":
return "files";
case "pull_request":
return "pr";
}
}
/** Explicitly place a companion tab in the Side panel, moving an existing tab there. */
export function openTabInSidePanel(
input: SidePanelInput & { target: WorkspaceTabTarget },