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