From c60fa098ac4d65dcb31a0948b1ca2f1627bb23f7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 22 Aug 2026 21:30:15 +0200 Subject: [PATCH] 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 --- .../e2e/browser/composer-diff-stat.spec.ts | 19 + .../e2e/browser/empty-pane-keyboard.spec.ts | 17 +- .../browser/explorer-pane-placement.spec.ts | 34 +- .../browser/explorer-surface-upgrade.spec.ts | 6 +- packages/app/e2e/browser/file-editing.spec.ts | 5 +- packages/app/e2e/browser/launcher-tab.spec.ts | 142 +++-- .../app/e2e/browser/new-tab-startup.spec.ts | 49 ++ packages/app/e2e/browser/side-panel.spec.ts | 69 +-- .../e2e/browser/terminal-split-resize.spec.ts | 2 +- .../e2e/browser/terminal-stuck-size.spec.ts | 6 +- .../workspace-agent-title-handoff.spec.ts | 2 +- .../browser/workspace-pane-remount.spec.ts | 4 +- .../workspace-terminal-tab-rename.spec.ts | 11 +- packages/app/e2e/support/helpers/launcher.ts | 28 +- .../app/e2e/support/helpers/workspace-tabs.ts | 23 +- .../app/src/components/split-container.tsx | 116 +--- packages/app/src/components/ui/shortcut.tsx | 2 +- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- packages/app/src/i18n/resources/es.ts | 2 +- packages/app/src/i18n/resources/fr.ts | 2 +- packages/app/src/i18n/resources/ja.ts | 2 +- packages/app/src/i18n/resources/ko.ts | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 2 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- packages/app/src/panels/agent-panel.tsx | 1 + packages/app/src/panels/agent-tracks.tsx | 5 +- packages/app/src/panels/new-tab-panel.tsx | 548 ++++++++++++++++++ packages/app/src/panels/register-panels.ts | 2 + .../terminals/use-workspace-terminals.ts | 20 +- .../workspace/workspace-desktop-tabs-row.tsx | 433 ++------------ .../workspace-empty-draft-seed.test.ts | 62 -- .../workspace/workspace-empty-draft-seed.ts | 24 - .../workspace/workspace-pane-empty-state.tsx | 277 --------- .../screens/workspace/workspace-screen.tsx | 392 +++++++------ .../screens/workspace/workspace-tab-menu.ts | 5 +- .../screens/workspace/workspace-tab-model.ts | 5 - .../src/stores/workspace-layout-actions.ts | 184 +++++- .../src/stores/workspace-layout-store.test.ts | 323 +++++++++-- .../app/src/stores/workspace-layout-store.ts | 32 +- .../workspace-subagents-integration.test.ts | 16 +- .../app/src/workspace-tabs/identity.test.ts | 12 + packages/app/src/workspace-tabs/identity.ts | 6 + packages/app/src/workspace-tabs/model.ts | 1 + packages/app/src/workspace-tabs/new-tab.ts | 17 + .../app/src/workspace-tabs/side-panel.test.ts | 18 +- packages/app/src/workspace-tabs/side-panel.ts | 21 +- 48 files changed, 1663 insertions(+), 1294 deletions(-) create mode 100644 packages/app/e2e/browser/new-tab-startup.spec.ts create mode 100644 packages/app/src/panels/new-tab-panel.tsx delete mode 100644 packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts delete mode 100644 packages/app/src/screens/workspace/workspace-empty-draft-seed.ts delete mode 100644 packages/app/src/screens/workspace/workspace-pane-empty-state.tsx create mode 100644 packages/app/src/workspace-tabs/new-tab.ts diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index e8ff9302dd..003273bc6b 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -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, }) => { diff --git a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts index f0a32857c2..2f8ecefafe 100644 --- a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts +++ b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts @@ -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 { - await expect(launcher.getByRole("button", { name })).toBeFocused(); + await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused(); } async function openSidePanelWithKeyboard(page: Page): Promise { @@ -19,12 +19,12 @@ async function openSidePanelWithKeyboard(page: Page): Promise { 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({ diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index cdca2cf912..7dc42f7b31 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -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 { 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) diff --git a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts index 29b6b5256b..0da548e08e 100644 --- a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts +++ b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts @@ -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(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index d2e11f23fb..daedee41ad 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -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(); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index e3087950ed..f7011c1a59 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -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 }); }); diff --git a/packages/app/e2e/browser/new-tab-startup.spec.ts b/packages/app/e2e/browser/new-tab-startup.spec.ts new file mode 100644 index 0000000000..28d72d6dfb --- /dev/null +++ b/packages/app/e2e/browser/new-tab-startup.spec.ts @@ -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(); + } +}); diff --git a/packages/app/e2e/browser/side-panel.spec.ts b/packages/app/e2e/browser/side-panel.spec.ts index a48de52135..45920114ef 100644 --- a/packages/app/e2e/browser/side-panel.spec.ts +++ b/packages/app/e2e/browser/side-panel.spec.ts @@ -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 { 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, diff --git a/packages/app/e2e/browser/terminal-split-resize.spec.ts b/packages/app/e2e/browser/terminal-split-resize.spec.ts index ab48305505..b0fba714d7 100644 --- a/packages/app/e2e/browser/terminal-split-resize.spec.ts +++ b/packages/app/e2e/browser/terminal-split-resize.spec.ts @@ -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"); diff --git a/packages/app/e2e/browser/terminal-stuck-size.spec.ts b/packages/app/e2e/browser/terminal-stuck-size.spec.ts index ffca86b930..ab406e93bb 100644 --- a/packages/app/e2e/browser/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/browser/terminal-stuck-size.spec.ts @@ -59,8 +59,8 @@ async function createTerminalViaMenu(page: Page): Promise { // 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 { @@ -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, }); diff --git a/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts index 5984b2be35..3bf78d998e 100644 --- a/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts +++ b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts @@ -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"); diff --git a/packages/app/e2e/browser/workspace-pane-remount.spec.ts b/packages/app/e2e/browser/workspace-pane-remount.spec.ts index 1f0efec749..9d7f286307 100644 --- a/packages/app/e2e/browser/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/browser/workspace-pane-remount.spec.ts @@ -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 }, ); diff --git a/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts index 33eba86764..80ba958837 100644 --- a/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts +++ b/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts @@ -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(); diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index f1cdde984f..bb300268ce 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise { // ─── 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 { const modifier = process.platform === "darwin" ? "Meta" : "Control"; await page.keyboard.press(`${modifier}+t`); @@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise { export async function openNewTabMenuWithShortcut(page: Page): Promise { 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 { @@ -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 { 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 { 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 { 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 { - 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 { - 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 { diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 1ddee12829..4f9b64de0c 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -88,13 +88,16 @@ export async function ensureSidePanel(page: Page): Promise { */ async function openSidePanelView( page: Page, - view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number }, + view: { tabTestId: string; launcherTestId: string; contentTestId: string; timeout?: number }, ): Promise { 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 { 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 { export async function openFilesPanel(page: Page): Promise { 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 { } 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 { 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 { - 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(); } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 269c7ff784..2f0c275321 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -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; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | 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 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 ( - {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 ( - - ); - }) - : (renderPaneEmptyState?.({ - paneId, - paneIsolated, - isFocused: isFocused && isWorkspaceFocused, - onClosePane: handleClosePane, - }) ?? null)} + return ( + + ); + })} diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index 6b9e006004..d53788cb00 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -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: { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index d8b97fd35c..b27b45489e 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -605,7 +605,7 @@ export const ar: TranslationResources = { files: "الملفات", pullRequest: "طلب السحب", terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles…", + editTerminalProfiles: "Edit profiles", }, sidePanel: { open: "افتح اللوحة الجانبية", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c37ac6ea27..d874b9db8c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ceb0c259b0..66499703ae 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7155aaabd3..0be7692795 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 00d771edca..3e699429f9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -610,7 +610,7 @@ export const ja: TranslationResources = { files: "ファイル", pullRequest: "プルリクエスト", terminalProfilesMenu: "ターミナルプロファイル", - editTerminalProfiles: "プロファイルを編集…", + editTerminalProfiles: "プロファイルを編集", }, sidePanel: { open: "サイドパネルを開く", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 389ee18e0b..483c3d03ad 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -607,7 +607,7 @@ export const ko: TranslationResources = { files: "파일", pullRequest: "풀 리퀘스트", terminalProfilesMenu: "터미널 프로필", - editTerminalProfiles: "프로필 편집…", + editTerminalProfiles: "프로필 편집", }, sidePanel: { open: "사이드 패널 열기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index ad4a1f8bc1..1ca1661745 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9f3b91aaeb..b383b59f45 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -610,7 +610,7 @@ export const ru: TranslationResources = { files: "Файлы", pullRequest: "PR", terminalProfilesMenu: "Профили терминала", - editTerminalProfiles: "Изменить профили…", + editTerminalProfiles: "Изменить профили", }, sidePanel: { open: "Открыть боковую панель", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index ff1a43087e..be7b816b04 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -605,7 +605,7 @@ export const zhCN: TranslationResources = { files: "文件", pullRequest: "拉取请求", terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles…", + editTerminalProfiles: "Edit profiles", }, sidePanel: { open: "打开侧边面板", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index bd79e7a515..13680676c5 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1350,6 +1350,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ void; +} + +const NewTabLauncherContext = createContext(null); + +export function NewTabLauncherProvider({ + value, + children, +}: { + value: NewTabLauncher; + children: ReactNode; +}) { + return {children}; +} + +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 ; +} + +const ThemedLauncherIcon = withUnistyles(LauncherIcon); + +function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null { + const keys = useShortcutKeys(actionId); + return 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 ( + + + + + + ); +} + +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 ( + + {Icon ? : null} + {terminalIconKey ? ( + + ) : null} + + {label} + + {shortcut ? : null} + + ); +} + +function TerminalProfileLauncherRow({ + profile, + disabled, +}: { + profile: TerminalProfile; + disabled: boolean; +}) { + const selection = useMemo(() => ({ kind: "terminal", profile }), [profile]); + return ( + + ); +} + +function PluginLauncherRow({ + pluginId, + panel, +}: { + pluginId: string; + panel: PluginWorkspacePanelContribution; +}) { + const selection = useMemo( + () => ({ + kind: "target", + target: { kind: "plugin", pluginId, panelId: panel.id, context: "workspace" }, + }), + [panel.id, pluginId], + ); + return ( + + ); +} + +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(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 ( + + + + + {launcher.showChanges ? ( + + ) : null} + + {launcher.showPullRequest ? ( + + ) : null} + + + {launcher.showBrowser ? ( + + ) : null} + {pluginPanels.map(({ pluginId, panel }) => ( + + ))} + + {profiles.length > 0 ? ( + + + + {t("workspace.tabs.actions.terminalProfilesMenu")} + + + + {profiles.map((profile) => ( + + ))} + + ) : null} + + + + ); +}); + +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, + }, +})); diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index cb6c86dad3..9003cabb65 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -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); diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index eaa4e09e5e..e35e9d44a4 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -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(); }, [ diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 1931bab45b..b2f1dd66a7 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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 = ; -const TERMINAL_ICON = ; -const BROWSER_ICON = ; -const CHANGES_ICON = ; -const FILES_ICON = ; -const PULL_REQUEST_ICON = ; - -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( - () => , - [profile], - ); - const handleSelect = useCallback(() => onLaunch(profile), [onLaunch, profile]); - - return ( - - {profile.name} - - ); -} - -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 ? : undefined), - [agentKeys], - ); - const terminalShortcut = useMemo( - () => (terminalKeys ? : undefined), - [terminalKeys], - ); - const browserShortcut = useMemo( - () => (browserKeys ? : undefined), - [browserKeys], - ); - const changesShortcut = useMemo( - () => (changesKeys ? : undefined), - [changesKeys], - ); - const filesShortcut = useMemo( - () => (filesKeys ? : undefined), - [filesKeys], - ); return ( - - - - - - - - - - {tooltipText} - {shortcutKeys ? : null} - - - - - + + - {t("workspace.tabs.fallback.agent")} - - - {t("workspace.tabs.fallback.terminal")} - - {showCreateBrowserTab ? ( - - {t("workspace.tabs.fallback.browser")} - - ) : null} - {isGit ? ( - - {t("workspace.tabs.actions.changes")} - - ) : null} - - {t("workspace.tabs.actions.files")} - - {showPullRequest ? ( - - {t("workspace.tabs.actions.pullRequest")} - - ) : null} - - {t("workspace.tabs.actions.terminalProfilesMenu")} - {profiles.map((profile) => ( - - ))} - - - {t("workspace.tabs.actions.editTerminalProfiles")} - - - + + + + + + {tooltipText} + {shortcutKeys ? : null} + + + ); } @@ -669,12 +465,7 @@ interface WorkspaceDesktopTabsRowProps { onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | 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({ @@ -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" ? ( @@ -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(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); const [paneMaximizeButtonWidth, setPaneMaximizeButtonWidth] = useState(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(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(), ); const [trackSnapshot, setTrackSnapshot] = useState(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 ? ( ) : null} @@ -1698,22 +1361,8 @@ function ResolvedWorkspaceDesktopTabsRow({ {layout.requiresHorizontalScrollFallback ? ( ) : null} diff --git a/packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts b/packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts deleted file mode 100644 index 7dfa44326a..0000000000 --- a/packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts +++ /dev/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); - }); -}); diff --git a/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts b/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts deleted file mode 100644 index 76c3678a90..0000000000 --- a/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts +++ /dev/null @@ -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; -} diff --git a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx b/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx deleted file mode 100644 index ba040709fa..0000000000 --- a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx +++ /dev/null @@ -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 = ; -const CHANGES_ICON = ; -const PULL_REQUEST_ICON = ; -const AGENT_ICON = ; -const TERMINAL_ICON = ; - -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 ? : 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 ` - ) : null} - - - ); -}); - -// 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", - }, -})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a642b7d6f2..5048b81908 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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; 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 ); } - if (!activeTabDescriptor && !hasHydratedAgents) { + if (!hasHydratedAgents || !hasLoadedTerminals) { return ( @@ -1270,6 +1278,20 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { ); } +function WorkspacePanelContent({ + launcher, + content, +}: { + launcher: NewTabLauncher; + content: ReactNode; +}) { + return ( + + {content} + + ); +} + 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(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( () => ({ + 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 ( - - ); - }, - [ - 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( + () => ({ + 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 = ( + + ); + const workspaceCenterColumn = ( {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} - - {isMobile ? ( - {content} - ) : ( - {desktopContent} - )} - + {workspacePanelContent} ); + const renderedWorkspaceScreen = ( + + + + + + {workspaceCenterColumn} + + + + + + + + ); + + if (gatedWorkspaceScreen) { + return gatedWorkspaceScreen; + } return ( - gatedWorkspaceScreen ?? ( - - - - - - - {workspaceCenterColumn} - - - - - - - - - ) + + {renderedWorkspaceScreen} + ); } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 8b57dc29a1..7b97f46c23 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -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({ diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index b7e6c967f8..5068d5779a 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -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; diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 04e4cd8def..18e91a2415 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -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 = 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; + knownTerminalIds: Set; + 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({ diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 66387bbcd0..56a8f1c2fa 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -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 { expect(node.kind).toBe("group"); return node as Extract; } 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", () => { diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index ecc1c1c977..8b4b0f2a75 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -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; diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index f56bac931c..0cbcfb5191 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -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(), diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts index b2859000ae..425acdb2d1 100644 --- a/packages/app/src/workspace-tabs/identity.test.ts +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -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({ diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index fc10fc105e..6e01210d14 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -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; } diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts index b059b3b2ac..0bda48a708 100644 --- a/packages/app/src/workspace-tabs/model.ts +++ b/packages/app/src/workspace-tabs/model.ts @@ -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 } diff --git a/packages/app/src/workspace-tabs/new-tab.ts b/packages/app/src/workspace-tabs/new-tab.ts new file mode 100644 index 0000000000..f667f4d6c0 --- /dev/null +++ b/packages/app/src/workspace-tabs/new-tab.ts @@ -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(), + }; +} diff --git a/packages/app/src/workspace-tabs/side-panel.test.ts b/packages/app/src/workspace-tabs/side-panel.test.ts index b5b763769f..e2a7430876 100644 --- a/packages/app/src/workspace-tabs/side-panel.test.ts +++ b/packages/app/src/workspace-tabs/side-panel.test.ts @@ -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", () => { diff --git a/packages/app/src/workspace-tabs/side-panel.ts b/packages/app/src/workspace-tabs/side-panel.ts index e2f29020a2..da154529a4 100644 --- a/packages/app/src/workspace-tabs/side-panel.ts +++ b/packages/app/src/workspace-tabs/side-panel.ts @@ -107,8 +107,10 @@ export interface OpenSupportingTabInput extends SidePanelQuery { background?: boolean; } -export interface ToggleSupportingTabInput extends SidePanelQuery { - target: WorkspaceTabTarget; +type SidePanelViewTarget = Extract; + +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 },