Expand workspace panel actions and keyboard navigation (#3685)
CI / changes (push) Successful in 6m6s
Docker / setup (push) Successful in 14s
Docker / publish (push) Skipped
Docker / build (push) Failing after 10m1s
CI / relay-tests (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / server-tests (ubuntu-latest) (push) Canceled after 0s
CI / server-tests (windows-latest) (push) Canceled after 0s
CI / desktop-tests (ubuntu-latest) (push) Canceled after 0s
CI / desktop-tests (windows-latest) (push) Canceled after 0s
CI / app-tests (push) Canceled after 0s
CI / sdk-tests (push) Canceled after 0s
CI / playwright (shard 1/4) (push) Canceled after 0s
CI / playwright (shard 2/4) (push) Canceled after 0s
CI / playwright (shard 3/4) (push) Canceled after 0s
CI / playwright (shard 4/4) (push) Canceled after 0s
CI / format (push) Canceled after 0s
CI / cli-tests (shard 1/3) (push) Canceled after 0s
CI / cli-tests (shard 2/3) (push) Canceled after 0s
CI / cli-tests (shard 3/3) (push) Canceled after 0s

* fix(app): keep file navigation in place

Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements.

* feat(app): expand workspace panel actions

Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
This commit is contained in:
Mohamed Boudra
2026-08-22 14:15:23 +02:00
committed by GitHub
parent 3029b5991a
commit a69dca2907
25 changed files with 1101 additions and 78 deletions
+3 -1
View File
@@ -85,7 +85,9 @@ supporting tabs land in the focused pane instead. Mobile ignores it. Explicit ac
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject — the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
supporting opens: they follow the preference and focus an existing Changes tab wherever supporting opens: they follow the preference and focus an existing Changes tab wherever
the user left it. the user left it. When Changes is already visible in the Side panel, the diff-stat pill
hides the panel without closing the tab. A different visible Side panel tab switches to
Changes instead.
## The name ## The name
@@ -2,6 +2,7 @@ import { rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings"; const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -37,7 +38,7 @@ async function seedChangedAgent(repoPrefix: string) {
} }
} }
test("composer diff stat opens Changes in the preferred Side panel", async ({ page }) => { test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
const workspace = await seedChangedAgent("composer-diff-stat-side-"); const workspace = await seedChangedAgent("composer-diff-stat-side-");
try { try {
@@ -58,6 +59,26 @@ test("composer diff stat opens Changes in the preferred Side panel", async ({ pa
timeout: 30_000, timeout: 30_000,
}); });
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 }); await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
await test.step("switching from another Side panel view reveals Changes", async () => {
await openFilesPanel(page);
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await pill.click();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
await pill.click();
await expect(sidePanel).toHaveCount(0);
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
});
await test.step("clicking again restores Changes", async () => {
await pill.click();
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
});
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -0,0 +1,79 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { gotoWorkspace } from "../support/helpers/launcher";
import { 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 });
}
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
await expect(launcher.getByRole("button", { name })).toBeFocused();
}
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+E`);
const launcher = page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-pane-empty-state");
await expect(launcher).toBeVisible({ timeout: 30_000 });
return launcher;
}
test.describe("Empty pane 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-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const launcher = await openSidePanelWithKeyboard(page);
await expectLauncherSelection(launcher, "Changes");
await page.keyboard.press("ArrowDown");
await expectLauncherSelection(launcher, "Files");
await page.keyboard.press("Enter");
await expect(
page
.getByTestId("workspace-side-panel")
.filter({ visible: true })
.getByTestId("workspace-tab-files"),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
test("a new split pane focuses its launcher and wraps arrow navigation", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const launcher = visibleEmptyPane(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 page.keyboard.press("Enter");
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await workspace.cleanup();
}
});
});
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
finalText: string, finalText: string,
gate: Awaited<ReturnType<typeof delayFileReadResponse>>, gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
) { ) {
await page.evaluate(() => {
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
(
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
).__fileTransitionOriginalChip = originalChip;
});
await page.evaluate((tabTestId) => { await page.evaluate((tabTestId) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = []; const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => { const sample = () => {
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0); await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0); await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
const chipIdentity = await page.evaluate((tabTestId) => {
const windowWithOriginal = window as typeof window & {
__fileTransitionOriginalChip?: Element | null;
};
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
return {
originalStillConnected: originalChip?.isConnected ?? false,
sameNode: originalChip === currentChip,
};
}, `workspace-tab-file_${targetPath}`);
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
const heldTimeline = await page.evaluate( const heldTimeline = await page.evaluate(
() => () =>
( (
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
newBrowser: "New browser", newBrowser: "New browser",
splitRight: "Split pane right", splitRight: "Split pane right",
splitDown: "Split pane down", splitDown: "Split pane down",
changes: "Changes",
files: "Files",
pullRequest: "Pull request",
openPanel: (name, placement) => `Open ${name} ${placement}`,
previousTab: "Previous tab",
nextTab: "Next tab",
closeCurrentTab: "Close current tab",
renameTab: "Rename",
reloadAgent: "Reload agent",
copyResumeCommand: "Copy resume command",
copyAgentId: "Copy agent id",
copyTerminalId: "Copy terminal id",
copyFilePath: "Copy file path",
closeTabsLeft: "Close tabs left",
closeTabsRight: "Close tabs right",
closeOtherTabs: "Close other tabs",
focusPaneLeft: "Focus pane left",
focusPaneRight: "Focus pane right",
focusPaneUp: "Focus pane up",
focusPaneDown: "Focus pane down",
moveTabLeft: "Move tab left",
moveTabRight: "Move tab right",
moveTabUp: "Move tab up",
moveTabDown: "Move tab down",
closePane: "Close pane",
togglePaneMaximization: "Toggle pane maximization",
toggleFocusMode: "Toggle focus mode",
toggleSidePanel: "Toggle side panel",
}, },
icons: {}, icons: {},
shortcuts: {}, shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true }, capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
dispatch: (action) => dispatched.push(action), dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action), runGitAction: (action) => runGitActions.push(action),
}, },
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value); const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([ expect(
{ id: "tab:new-agent", rank: 0, visibility: "always" }, contributions
{ id: "git:commit", rank: 1, visibility: "always" }, .filter((item) =>
{ id: "tab:new-terminal", rank: 2, visibility: "query" }, [
{ id: "tab:new-browser", rank: 3, visibility: "query" }, "tab:new-agent",
{ id: "pane:split-right", rank: 4, visibility: "query" }, "git:commit",
{ id: "pane:split-down", rank: 5, visibility: "query" }, "tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
].includes(item.id),
)
.map(({ id, visibility }) => ({ id, visibility })),
).toEqual([
{ id: "tab:new-agent", visibility: "always" },
{ id: "git:commit", visibility: "always" },
{ id: "tab:new-terminal", visibility: "query" },
{ id: "tab:new-browser", visibility: "query" },
{ id: "pane:split-right", visibility: "query" },
{ id: "pane:split-down", visibility: "query" },
]); ]);
}); });
it("omits browser and split actions when their existing capabilities are unavailable", () => { it("omits browser and split actions when their existing capabilities are unavailable", () => {
const fixture = source({ primary: null, secondary: [], menu: [] }); const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false }; fixture.value.capabilities = {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value); const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]); expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
}); });
it("dispatches every tab and pane command to the workspace scope", () => { it("dispatches every tab and pane command to the workspace scope", () => {
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run(); for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([ expect(fixture.dispatched.length).toBeGreaterThan(5);
{ id: "workspace.agent.new", scope: "workspace" }, expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
{ id: "workspace.terminal.new", scope: "workspace" },
{ id: "workspace.browser.new", scope: "workspace" },
{ id: "workspace.pane.split.right", scope: "workspace" },
{ id: "workspace.pane.split.down", scope: "workspace" },
]);
}); });
it("keeps workspace creation commands available outside Git", () => { it("keeps workspace creation commands available outside Git", () => {
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value); const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([ expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
"tab:new-agent", expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
"tab:new-terminal", expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
"tab:new-browser", expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
"pane:split-right", expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
"pane:split-down",
]);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false); expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
}); });
}); });
@@ -1,5 +1,10 @@
import type { GitAction, GitActions } from "@/git/policy"; import type { GitAction, GitActions } from "@/git/policy";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type {
WorkspacePanelPlacement,
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
newBrowser: string; newBrowser: string;
splitRight: string; splitRight: string;
splitDown: string; splitDown: string;
changes: string;
files: string;
pullRequest: string;
openPanel(name: string, placement: WorkspacePanelPlacement): string;
previousTab: string;
nextTab: string;
closeCurrentTab: string;
renameTab: string;
reloadAgent: string;
copyResumeCommand: string;
copyAgentId: string;
copyTerminalId: string;
copyFilePath: string;
closeTabsLeft: string;
closeTabsRight: string;
closeOtherTabs: string;
focusPaneLeft: string;
focusPaneRight: string;
focusPaneUp: string;
focusPaneDown: string;
moveTabLeft: string;
moveTabRight: string;
moveTabUp: string;
moveTabDown: string;
closePane: string;
togglePaneMaximization: string;
toggleFocusMode: string;
toggleSidePanel: string;
} }
export interface WorkspaceCommandCenterIcons { export interface WorkspaceCommandCenterIcons {
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
newBrowser?: CommandCenterIcon; newBrowser?: CommandCenterIcon;
splitRight?: CommandCenterIcon; splitRight?: CommandCenterIcon;
splitDown?: CommandCenterIcon; splitDown?: CommandCenterIcon;
changes?: CommandCenterIcon;
files?: CommandCenterIcon;
pullRequest?: CommandCenterIcon;
previousTab?: CommandCenterIcon;
nextTab?: CommandCenterIcon;
close?: CommandCenterIcon;
rename?: CommandCenterIcon;
reload?: CommandCenterIcon;
copy?: CommandCenterIcon;
focusPane?: CommandCenterIcon;
moveTab?: CommandCenterIcon;
maximize?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
sidePanel?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined; git?(action: GitAction): CommandCenterIcon | undefined;
} }
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
splitRight?: ShortcutKey[][]; splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][]; splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][]; archiveWorkspace?: ShortcutKey[][];
previousTab?: ShortcutKey[][];
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
} }
export interface WorkspaceCommandCenterSource { export interface WorkspaceCommandCenterSource {
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
capabilities: { capabilities: {
canSplitPanes: boolean; canSplitPanes: boolean;
canOpenBrowserTabs: boolean; canOpenBrowserTabs: boolean;
isGit: boolean;
}; };
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
dispatch(action: KeyboardActionDefinition): void; dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void; runGitAction(action: GitAction): void;
} }
@@ -96,7 +154,322 @@ function buildWorkspaceAction(input: {
}; };
} }
export function buildWorkspaceCommandCenterContributions( type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
function buildQueryAction(
source: WorkspaceCommandCenterSource,
input: QueryActionInput,
): CommandCenterContribution {
return buildWorkspaceAction({ ...input, source, visibility: "query" });
}
function buildPanelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
const panelTargets: Array<{
target: WorkspacePanelTarget;
name: string;
icon?: CommandCenterIcon;
}> = [
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
{ target: "files", name: source.labels.files, icon: source.icons.files },
{
target: "pull-request",
name: source.labels.pullRequest,
icon: source.icons.pullRequest,
},
];
for (const [index, panel] of panelTargets.entries()) {
if (panel.target !== "files" && !source.capabilities.isGit) continue;
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}`,
rank: 4 + index,
title: source.labels.openPanel(panel.name, "supporting"),
keywords: ["open", panel.target, "tab", "supporting"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement: "supporting",
},
}),
);
if (!source.capabilities.canSplitPanes) continue;
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
contributions.push(
buildQueryAction(source, {
id: `tab:open:${panel.target}:${placement}`,
rank: 10 + index * 2 + placementIndex,
title: source.labels.openPanel(panel.name, placement),
keywords: ["open", panel.target, "tab", placement, "pane"],
icon: panel.icon,
action: {
id: "workspace.tab.open",
scope: "workspace",
target: panel.target,
placement,
},
}),
);
}
}
return contributions;
}
function buildActiveTabContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (source.activeTabCount === 0) return [];
const contributions: CommandCenterContribution[] = [
buildQueryAction(source, {
id: "tab:previous",
rank: 30,
title: source.labels.previousTab,
keywords: ["tab", "previous", "switch", "navigate"],
icon: source.icons.previousTab,
shortcutKeys: source.shortcuts.previousTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
}),
buildQueryAction(source, {
id: "tab:next",
rank: 31,
title: source.labels.nextTab,
keywords: ["tab", "next", "switch", "navigate"],
icon: source.icons.nextTab,
shortcutKeys: source.shortcuts.nextTab,
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
}),
buildQueryAction(source, {
id: "tab:close-current",
rank: 32,
title: source.labels.closeCurrentTab,
keywords: ["tab", "close", "current"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closeCurrentTab,
action: { id: "workspace.tab.close-current", scope: "workspace" },
}),
];
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
contributions.push(
buildQueryAction(source, {
id: "tab:rename-current",
rank: 33,
title: source.labels.renameTab,
keywords: ["tab", "rename", "current"],
icon: source.icons.rename,
action: { id: "workspace.tab.rename-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-id",
rank: 34,
title:
source.activeTabKind === "agent"
? source.labels.copyAgentId
: source.labels.copyTerminalId,
keywords: ["tab", "copy", "id"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-id", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "agent") {
contributions.push(
buildQueryAction(source, {
id: "tab:reload-current",
rank: 35,
title: source.labels.reloadAgent,
keywords: ["tab", "agent", "reload"],
icon: source.icons.reload,
action: { id: "workspace.tab.reload-current", scope: "workspace" },
}),
buildQueryAction(source, {
id: "tab:copy-resume-command",
rank: 36,
title: source.labels.copyResumeCommand,
keywords: ["tab", "agent", "copy", "resume", "command"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
}),
);
}
if (source.activeTabKind === "file") {
contributions.push(
buildQueryAction(source, {
id: "tab:copy-file-path",
rank: 37,
title: source.labels.copyFilePath,
keywords: ["tab", "file", "copy", "path"],
icon: source.icons.copy,
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
}),
);
}
if (source.activeTabIndex > 0) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-left",
rank: 38,
title: source.labels.closeTabsLeft,
keywords: ["tab", "close", "left"],
icon: source.icons.close,
action: { id: "workspace.tab.close-left", scope: "workspace" },
}),
);
}
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-right",
rank: 39,
title: source.labels.closeTabsRight,
keywords: ["tab", "close", "right"],
icon: source.icons.close,
action: { id: "workspace.tab.close-right", scope: "workspace" },
}),
);
}
if (source.activeTabCount > 1) {
contributions.push(
buildQueryAction(source, {
id: "tab:close-others",
rank: 40,
title: source.labels.closeOtherTabs,
keywords: ["tab", "close", "others"],
icon: source.icons.close,
action: { id: "workspace.tab.close-others", scope: "workspace" },
}),
);
}
return contributions;
}
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
const paneActions: Array<{
id: string;
rank: number;
title: string;
keywords: string[];
action: KeyboardActionDefinition;
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
}> = [
{
id: "pane:split-right",
rank: 50,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
},
{
id: "pane:split-down",
rank: 51,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
},
{
id: "pane:focus-left",
rank: 52,
title: source.labels.focusPaneLeft,
keywords: ["pane", "focus", "left"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.left", scope: "workspace" },
},
{
id: "pane:focus-right",
rank: 53,
title: source.labels.focusPaneRight,
keywords: ["pane", "focus", "right"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.right", scope: "workspace" },
},
{
id: "pane:focus-up",
rank: 54,
title: source.labels.focusPaneUp,
keywords: ["pane", "focus", "up"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.up", scope: "workspace" },
},
{
id: "pane:focus-down",
rank: 55,
title: source.labels.focusPaneDown,
keywords: ["pane", "focus", "down"],
icon: source.icons.focusPane,
action: { id: "workspace.pane.focus.down", scope: "workspace" },
},
{
id: "pane:move-tab-left",
rank: 56,
title: source.labels.moveTabLeft,
keywords: ["pane", "tab", "move", "left"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
},
{
id: "pane:move-tab-right",
rank: 57,
title: source.labels.moveTabRight,
keywords: ["pane", "tab", "move", "right"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
},
{
id: "pane:move-tab-up",
rank: 58,
title: source.labels.moveTabUp,
keywords: ["pane", "tab", "move", "up"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
},
{
id: "pane:move-tab-down",
rank: 59,
title: source.labels.moveTabDown,
keywords: ["pane", "tab", "move", "down"],
icon: source.icons.moveTab,
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
},
{
id: "pane:close",
rank: 60,
title: source.labels.closePane,
keywords: ["pane", "close"],
icon: source.icons.close,
shortcutKeys: source.shortcuts.closePane,
action: { id: "workspace.pane.close", scope: "workspace" },
},
{
id: "pane:maximize-toggle",
rank: 61,
title: source.labels.togglePaneMaximization,
keywords: ["pane", "maximize", "restore", "toggle"],
icon: source.icons.maximize,
shortcutKeys: source.shortcuts.togglePaneMaximization,
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
},
{
id: "pane:focus-mode-toggle",
rank: 62,
title: source.labels.toggleFocusMode,
keywords: ["pane", "focus", "mode", "toggle"],
icon: source.icons.focusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
},
];
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource, source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] { ): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [ const contributions: CommandCenterContribution[] = [
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
const primary = source.gitActions.primary; const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always")); if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push( contributions.push(
buildWorkspaceAction({ buildQueryAction(source, {
source,
id: "tab:new-terminal", id: "tab:new-terminal",
rank: 2, rank: 2,
title: source.labels.newTerminal, title: source.labels.newTerminal,
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
icon: source.icons.newTerminal, icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal, shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" }, action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}), }),
); );
if (source.capabilities.canOpenBrowserTabs) { if (source.capabilities.canOpenBrowserTabs) {
contributions.push( contributions.push(
buildWorkspaceAction({ buildQueryAction(source, {
source,
id: "tab:new-browser", id: "tab:new-browser",
rank: 3, rank: 3,
title: source.labels.newBrowser, title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"], keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser, icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" }, action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}), }),
); );
} }
if (source.capabilities.canSplitPanes) { return contributions;
contributions.push( }
buildWorkspaceAction({
source, export function buildWorkspaceCommandCenterContributions(
id: "pane:split-right", source: WorkspaceCommandCenterSource,
rank: 4, ): CommandCenterContribution[] {
title: source.labels.splitRight, const contributions = [
keywords: ["split", "pane", "vertical"], ...buildCreationContributions(source),
icon: source.icons.splitRight, ...buildPanelContributions(source),
shortcutKeys: source.shortcuts.splitRight, ...buildActiveTabContributions(source),
action: { id: "workspace.pane.split.right", scope: "workspace" }, ...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
visibility: "query", buildQueryAction(source, {
id: "side-panel:toggle",
rank: 70,
title: source.labels.toggleSidePanel,
keywords: ["side", "panel", "toggle", "show", "hide"],
icon: source.icons.sidePanel,
shortcutKeys: source.shortcuts.toggleSidePanel,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}), }),
buildWorkspaceAction({ ];
source, const primary = source.gitActions.primary;
id: "pane:split-down",
rank: 5,
title: source.labels.splitDown,
keywords: ["split", "pane", "horizontal"],
icon: source.icons.splitDown,
shortcutKeys: source.shortcuts.splitDown,
action: { id: "workspace.pane.split.down", scope: "workspace" },
visibility: "query",
}),
);
}
for (const [index, action] of source.gitActions.secondary.entries()) { for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue; if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query")); contributions.push(buildGitContribution(source, action, 100 + index, "query"));
} }
return contributions; return contributions;
} }
@@ -1,6 +1,26 @@
import { useMemo, type ReactElement } from "react"; import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native"; import {
ArrowDownToLine,
ArrowLeft,
ArrowRight,
Columns2,
Copy,
Files,
Focus,
GitCompareArrows,
GitPullRequest,
Globe,
Maximize2,
Move,
PanelRight,
Pencil,
RotateCw,
Rows2,
SquarePen,
SquareTerminal,
X,
} from "lucide-react-native";
import { getIsElectron } from "@/constants/platform"; import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { GIT_ACTION_ICONS } from "@/git/action-icons";
@@ -13,6 +33,12 @@ import {
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon"; import { getCommandCenterIcon } from "./icon";
@@ -29,8 +55,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
newBrowser: getCommandCenterIcon(Globe), newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2), splitRight: getCommandCenterIcon(Columns2),
splitDown: getCommandCenterIcon(Rows2), splitDown: getCommandCenterIcon(Rows2),
changes: getCommandCenterIcon(GitCompareArrows),
files: getCommandCenterIcon(Files),
pullRequest: getCommandCenterIcon(GitPullRequest),
previousTab: getCommandCenterIcon(ArrowLeft),
nextTab: getCommandCenterIcon(ArrowRight),
close: getCommandCenterIcon(X),
rename: getCommandCenterIcon(Pencil),
reload: getCommandCenterIcon(RotateCw),
copy: getCommandCenterIcon(Copy),
focusPane: getCommandCenterIcon(Focus),
moveTab: getCommandCenterIcon(Move),
maximize: getCommandCenterIcon(Maximize2),
focusMode: getCommandCenterIcon(ArrowDownToLine),
sidePanel: getCommandCenterIcon(PanelRight),
}; };
const OPEN_PANEL_LABEL_KEYS = {
supporting: "shell.commandCenter.open",
"side-panel": "shell.commandCenter.openInSidePanel",
"focused-pane": "shell.commandCenter.openInFocusedPane",
} as const;
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined { function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
if (!element) return undefined; if (!element) return undefined;
function StaticIcon() { function StaticIcon() {
@@ -51,6 +97,17 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined, resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace: archiveWorkspace:
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined, resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
previousTab:
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
closeCurrentTab:
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
togglePaneMaximization:
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
toggleSidePanel:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
}; };
} }
@@ -60,10 +117,22 @@ export function useWorkspaceCommandCenterActions(): void {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null; const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null; const workspaceId = selection?.workspaceId ?? null;
const workspaceKey =
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
);
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
const focusedTabs = layout
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
: [];
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
const cwd = useWorkspaceDirectory(serverId, workspaceId); const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({ const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "", serverId: serverId ?? "",
cwd: cwd ?? "", cwd: cwd ?? "",
icons: GIT_ACTION_ICONS, icons: GIT_ACTION_ICONS,
@@ -81,6 +150,34 @@ export function useWorkspaceCommandCenterActions(): void {
newBrowser: t("workspace.tabs.actions.newBrowser"), newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"), splitRight: t("workspace.tabs.actions.splitRight"),
splitDown: t("workspace.tabs.actions.splitDown"), splitDown: t("workspace.tabs.actions.splitDown"),
changes: t("workspace.tabs.actions.changes"),
files: t("workspace.tabs.actions.files"),
pullRequest: t("workspace.tabs.actions.pullRequest"),
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
previousTab: t("settings.shortcuts.help.previousTab"),
nextTab: t("settings.shortcuts.help.nextTab"),
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
renameTab: t("workspace.tabs.menu.rename"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
closeTabsRight: t("workspace.tabs.menu.closeRight"),
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
closePane: t("settings.shortcuts.help.closePane"),
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
}, },
icons: { icons: {
...WORKSPACE_COMMAND_CENTER_ICONS, ...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -90,14 +187,29 @@ export function useWorkspaceCommandCenterActions(): void {
capabilities: { capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact, canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(), canOpenBrowserTabs: getIsElectron(),
isGit,
}, },
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
dispatch: (action) => { dispatch: (action) => {
clearCommandCenterFocusRestoreElement(); clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action); keyboardActionDispatcher.dispatch(action);
}, },
runGitAction, runGitAction,
}), }),
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t], [
activeTabIndex,
activeTabKind,
focusedTabs.length,
gitActions,
isCompact,
isGit,
keyboardActionDispatcher,
overrides,
runGitAction,
t,
],
); );
useCommandCenterActions({ useCommandCenterActions({
@@ -135,6 +135,7 @@ interface SplitContainerProps {
renderPaneEmptyState?: (input: { renderPaneEmptyState?: (input: {
paneId: string; paneId: string;
paneIsolated: boolean; paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void; onClosePane: () => void;
}) => ReactNode; }) => ReactNode;
focusModeEnabled?: boolean; focusModeEnabled?: boolean;
@@ -1289,6 +1290,7 @@ function SplitPaneView({
: (renderPaneEmptyState?.({ : (renderPaneEmptyState?.({
paneId, paneId,
paneIsolated, paneIsolated,
isFocused: isFocused && isWorkspaceFocused,
onClosePane: handleClosePane, onClosePane: handleClosePane,
}) ?? null)} }) ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} /> <SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
+3
View File
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
workspaces: "مساحات العمل", workspaces: "مساحات العمل",
agents: "الوكلاء", agents: "الوكلاء",
newAgent: "وكيل جديد", newAgent: "وكيل جديد",
open: "فتح {{name}}",
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
openInFocusedPane: "فتح {{name}} في الجزء النشط",
addProject: "إضافة مشروع", addProject: "إضافة مشروع",
home: "بيت", home: "بيت",
groupByProject: "تجميع حسب المشروع", groupByProject: "تجميع حسب المشروع",
+3
View File
@@ -61,6 +61,9 @@ export const en = {
workspaces: "Workspaces", workspaces: "Workspaces",
agents: "Agents", agents: "Agents",
newAgent: "New agent", newAgent: "New agent",
open: "Open {{name}}",
openInSidePanel: "Open {{name}} in Side panel",
openInFocusedPane: "Open {{name}} in focused pane",
addProject: "Add project", addProject: "Add project",
home: "Home", home: "Home",
groupByProject: "Group by project", groupByProject: "Group by project",
+3
View File
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
workspaces: "Espacios de trabajo", workspaces: "Espacios de trabajo",
agents: "Agentes", agents: "Agentes",
newAgent: "Nuevo agente", newAgent: "Nuevo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} en el panel lateral",
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
addProject: "Agregar proyecto", addProject: "Agregar proyecto",
home: "Hogar", home: "Hogar",
groupByProject: "Agrupar por proyecto", groupByProject: "Agrupar por proyecto",
+3
View File
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
workspaces: "Espaces de travail", workspaces: "Espaces de travail",
agents: "Agents", agents: "Agents",
newAgent: "Nouvel agent", newAgent: "Nouvel agent",
open: "Ouvrir {{name}}",
openInSidePanel: "Ouvrir {{name}} dans le panneau latéral",
openInFocusedPane: "Ouvrir {{name}} dans le volet actif",
addProject: "Ajouter un projet", addProject: "Ajouter un projet",
home: "Maison", home: "Maison",
groupByProject: "Grouper par projet", groupByProject: "Grouper par projet",
+3
View File
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
workspaces: "ワークスペース", workspaces: "ワークスペース",
agents: "エージェント", agents: "エージェント",
newAgent: "新しいエージェント", newAgent: "新しいエージェント",
open: "{{name}}を開く",
openInSidePanel: "{{name}}をサイドパネルで開く",
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
addProject: "プロジェクトを追加", addProject: "プロジェクトを追加",
home: "ホーム", home: "ホーム",
groupByProject: "プロジェクトでグループ化", groupByProject: "プロジェクトでグループ化",
+3
View File
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
workspaces: "워크스페이스", workspaces: "워크스페이스",
agents: "에이전트", agents: "에이전트",
newAgent: "새 에이전트", newAgent: "새 에이전트",
open: "{{name}} 열기",
openInSidePanel: "사이드 패널에서 {{name}} 열기",
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
addProject: "프로젝트 추가", addProject: "프로젝트 추가",
home: "홈", home: "홈",
groupByProject: "프로젝트별 그룹화", groupByProject: "프로젝트별 그룹화",
+3
View File
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
workspaces: "Espaços de trabalho", workspaces: "Espaços de trabalho",
agents: "Agentes", agents: "Agentes",
newAgent: "Novo agente", newAgent: "Novo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} no painel lateral",
openInFocusedPane: "Abrir {{name}} no painel em foco",
addProject: "Adicionar projeto", addProject: "Adicionar projeto",
home: "Início", home: "Início",
groupByProject: "Agrupar por projeto", groupByProject: "Agrupar por projeto",
+3
View File
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
workspaces: "Рабочие пространства", workspaces: "Рабочие пространства",
agents: "Агенты", agents: "Агенты",
newAgent: "Новый агент", newAgent: "Новый агент",
open: "Открыть {{name}}",
openInSidePanel: "Открыть {{name}} в боковой панели",
openInFocusedPane: "Открыть {{name}} в активной области",
addProject: "Добавить проект", addProject: "Добавить проект",
home: "Главная", home: "Главная",
groupByProject: "Группировать по проекту", groupByProject: "Группировать по проекту",
+3
View File
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
workspaces: "工作区", workspaces: "工作区",
agents: "Agents", agents: "Agents",
newAgent: "新建 Agent", newAgent: "新建 Agent",
open: "打开{{name}}",
openInSidePanel: "在侧边面板中打开{{name}}",
openInFocusedPane: "在当前窗格中打开{{name}}",
addProject: "添加 project", addProject: "添加 project",
home: "首页", home: "首页",
groupByProject: "按项目分组", groupByProject: "按项目分组",
@@ -1,5 +1,8 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type WorkspacePanelTarget = "changes" | "files" | "pull-request";
export type WorkspacePanelPlacement = "supporting" | "side-panel" | "focused-pane";
export type KeyboardActionId = export type KeyboardActionId =
| "agent.interrupt" | "agent.interrupt"
| "message-input.focus" | "message-input.focus"
@@ -19,6 +22,15 @@ export type KeyboardActionId =
| "workspace.tab.close-current" | "workspace.tab.close-current"
| "workspace.tab.navigate-index" | "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative" | "workspace.tab.navigate-relative"
| "workspace.tab.open"
| "workspace.tab.rename-current"
| "workspace.tab.reload-current"
| "workspace.tab.copy-resume-command"
| "workspace.tab.copy-id"
| "workspace.tab.copy-file-path"
| "workspace.tab.close-left"
| "workspace.tab.close-right"
| "workspace.tab.close-others"
| "workspace.pane.split.right" | "workspace.pane.split.right"
| "workspace.pane.split.down" | "workspace.pane.split.down"
| "workspace.pane.focus.left" | "workspace.pane.focus.left"
@@ -61,6 +73,20 @@ export type KeyboardActionDefinition =
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope } | { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 } | { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
| {
id: "workspace.tab.open";
scope: KeyboardActionScope;
target: WorkspacePanelTarget;
placement: WorkspacePanelPlacement;
}
| { id: "workspace.tab.rename-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.reload-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-resume-command"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-id"; scope: KeyboardActionScope }
| { id: "workspace.tab.copy-file-path"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-left"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-right"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-others"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope } | { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope } | { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope } | { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
+2 -2
View File
@@ -17,7 +17,7 @@ import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream"; import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openSupportingTab } from "@/workspace-tabs/side-panel"; import { openSupportingTab, toggleSupportingTab } from "@/workspace-tabs/side-panel";
/** /**
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above * The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
@@ -96,7 +96,7 @@ export const AgentTracks = memo(function AgentTracks({
if (!workspaceKey) { if (!workspaceKey) {
return; return;
} }
openSupportingTab({ toggleSupportingTab({
isCompact, isCompact,
workspaceKey, workspaceKey,
target: { kind: "working_diff" }, target: { kind: "working_diff" },
@@ -1,4 +1,4 @@
import { memo, useCallback, type ReactElement, type ReactNode } from "react"; import { memo, useCallback, useEffect, useRef, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -11,6 +11,7 @@ import {
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
@@ -31,6 +32,7 @@ const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMappin
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" }; const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" }; const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" }; 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. */ /** The launcher's own binding, or nothing when the action has no shortcut. */
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null { function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
@@ -38,8 +40,19 @@ function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | nu
return keys ? <Shortcut chord={keys} /> : null; return keys ? <Shortcut chord={keys} /> : null;
} }
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) { const LAUNCHER_ROW_SELECTOR = '[data-empty-pane-launcher-row="true"]';
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
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,
];
} }
/** /**
@@ -62,6 +75,8 @@ function LauncherRow({
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={label} accessibilityLabel={label}
dataSet={LAUNCHER_ROW_DATA_SET}
tabIndex={-1}
onPress={onPress} onPress={onPress}
style={getLauncherRowStyle} style={getLauncherRowStyle}
> >
@@ -75,6 +90,8 @@ function LauncherRow({
export interface WorkspacePaneEmptyStateProps { export interface WorkspacePaneEmptyStateProps {
paneId: string; 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. */ /** Changes needs a Git workspace; the pull request needs one that has been detected. */
showChanges: boolean; showChanges: boolean;
showPullRequest: boolean; showPullRequest: boolean;
@@ -91,6 +108,7 @@ export interface WorkspacePaneEmptyStateProps {
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
paneId, paneId,
isFocused,
showChanges, showChanges,
showPullRequest, showPullRequest,
onOpenView, onOpenView,
@@ -99,6 +117,42 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
onClosePane, onClosePane,
}: WorkspacePaneEmptyStateProps): ReactElement { }: WorkspacePaneEmptyStateProps): ReactElement {
const { t } = useTranslation(); 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( const openFiles = useCallback(
() => onOpenView({ paneId, target: FILES_TARGET }), () => onOpenView({ paneId, target: FILES_TARGET }),
@@ -119,7 +173,7 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
); );
return ( return (
<View style={styles.container} testID="workspace-pane-empty-state"> <View ref={containerRef} style={styles.container} testID="workspace-pane-empty-state">
<View style={styles.rail}> <View style={styles.rail}>
<View style={styles.stack}> <View style={styles.stack}>
{showChanges ? ( {showChanges ? (
@@ -196,11 +250,18 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 44, minHeight: 44,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
outlineWidth: 0,
outlineColor: "transparent",
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
rowHovered: { rowHovered: {
backgroundColor: theme.colors.surface3, backgroundColor: theme.colors.surface3,
}, },
rowFocused: {
borderColor: theme.colors.borderAccent,
},
pressed: { pressed: {
opacity: 0.85, opacity: 0.85,
}, },
@@ -50,6 +50,7 @@ import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar
import { import {
isSidePanelOpen, isSidePanelOpen,
openSupportingTab, openSupportingTab,
openTabInSidePanel,
toggleSidePanel, toggleSidePanel,
useIsSidePanelOpen, useIsSidePanelOpen,
} from "@/workspace-tabs/side-panel"; } from "@/workspace-tabs/side-panel";
@@ -76,7 +77,10 @@ import {
import { useSettings } from "@/hooks/use-settings"; import { useSettings } from "@/hooks/use-settings";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type {
KeyboardActionDefinition,
WorkspacePanelTarget,
} from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import { import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
@@ -1423,6 +1427,17 @@ function useWorkspaceTerminalTabActions({
}; };
} }
function resolveCommandCenterPanelTarget(target: WorkspacePanelTarget): WorkspaceTabTarget {
switch (target) {
case "changes":
return { kind: "working_diff" };
case "files":
return { kind: "files" };
case "pull-request":
return { kind: "pull_request" };
}
}
function WorkspaceScreenContent({ function WorkspaceScreenContent({
serverId, serverId,
workspaceId, workspaceId,
@@ -2842,6 +2857,109 @@ function WorkspaceScreenContent({
], ],
); );
const handleWorkspacePanelOpenAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "workspace.tab.open") return false;
if (!persistenceKey) return true;
const target = resolveCommandCenterPanelTarget(action.target);
if (action.placement === "supporting") {
openSupportingTab({
isCompact: isMobile,
workspaceKey: persistenceKey,
target,
openInSidePanelByDefault,
});
return true;
}
if (action.placement === "side-panel") {
openTabInSidePanel({
isCompact: isMobile,
workspaceKey: persistenceKey,
checkout: activeExplorerCheckout,
target,
});
return true;
}
const focusedPaneId = focusedPaneTabState.pane?.id;
openWorkspaceTabFocused(
persistenceKey,
target,
focusedPaneId ? { mode: "pane", paneId: focusedPaneId } : undefined,
);
return true;
},
[
activeExplorerCheckout,
focusedPaneTabState.pane?.id,
isMobile,
openInSidePanelByDefault,
openWorkspaceTabFocused,
persistenceKey,
],
);
const handleWorkspaceCurrentTabMetadataAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
const descriptor = activeTab?.descriptor;
switch (action.id) {
case "workspace.tab.rename-current":
if (descriptor) handleRenameTab(descriptor);
return true;
case "workspace.tab.reload-current":
if (descriptor?.target.kind === "agent")
void handleReloadAgent(descriptor.target.agentId);
return true;
case "workspace.tab.copy-resume-command":
if (descriptor?.target.kind === "agent") {
void handleCopyResumeCommand(descriptor.target.agentId);
}
return true;
case "workspace.tab.copy-id":
if (descriptor?.target.kind === "agent")
void handleCopyAgentId(descriptor.target.agentId);
if (descriptor?.target.kind === "terminal") {
void handleCopyTerminalId(descriptor.target.terminalId);
}
return true;
case "workspace.tab.copy-file-path":
if (descriptor?.target.kind === "file") void handleCopyFilePath(descriptor.target.path);
return true;
default:
return false;
}
},
[
activeTab,
handleCopyAgentId,
handleCopyFilePath,
handleCopyResumeCommand,
handleCopyTerminalId,
handleReloadAgent,
handleRenameTab,
],
);
const handleWorkspaceCurrentTabCloseAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!activeTabId) return true;
if (action.id === "workspace.tab.close-left") {
void handleCloseTabsToLeft(activeTabId);
return true;
}
if (action.id === "workspace.tab.close-right") {
void handleCloseTabsToRight(activeTabId);
return true;
}
if (action.id === "workspace.tab.close-others") {
void handleCloseOtherTabs(activeTabId);
return true;
}
return false;
},
[activeTabId, handleCloseOtherTabs, handleCloseTabsToLeft, handleCloseTabsToRight],
);
const handleWorkspaceTabAction = useCallback( const handleWorkspaceTabAction = useCallback(
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
switch (action.id) { switch (action.id) {
@@ -2999,6 +3117,10 @@ function WorkspaceScreenContent({
], ],
); );
const workspaceKeyboardActionsEnabled = Boolean(
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
);
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({ handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-tab-actions", name: "workspace-tab-actions",
@@ -3013,12 +3135,61 @@ function WorkspaceScreenContent({
"workspace.terminal.new", "workspace.terminal.new",
"workspace.browser.new", "workspace.browser.new",
] as const, ] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), enabled: workspaceKeyboardActionsEnabled,
priority: 100, priority: 100,
isActive: () => true, isActive: () => true,
handle: handleWorkspaceTabAction, handle: handleWorkspaceTabAction,
}); });
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-panel-open-actions",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
actions: ["workspace.tab.open"] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePanelOpenAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-current-tab-metadata-actions",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
actions: [
"workspace.tab.rename-current",
"workspace.tab.reload-current",
"workspace.tab.copy-resume-command",
"workspace.tab.copy-id",
"workspace.tab.copy-file-path",
] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabMetadataAction,
});
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-current-tab-close-actions",
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
actions: [
"workspace.tab.close-left",
"workspace.tab.close-right",
"workspace.tab.close-others",
] as const,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabCloseAction,
});
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({ handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-pane-actions", name: "workspace-pane-actions",
@@ -3039,7 +3210,7 @@ function WorkspaceScreenContent({
"workspace.pane.close", "workspace.pane.close",
"workspace.focus.toggle", "workspace.focus.toggle",
] as const, ] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), enabled: workspaceKeyboardActionsEnabled,
priority: 100, priority: 100,
isActive: () => true, isActive: () => true,
handle: handleWorkspacePaneAction, handle: handleWorkspacePaneAction,
@@ -3052,7 +3223,7 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId, workspaceId: normalizedWorkspaceId,
}), }),
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const, actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), enabled: workspaceKeyboardActionsEnabled,
priority: 100, priority: 100,
isActive: () => true, isActive: () => true,
handle: handleWorkspaceSidebarAction, handle: handleWorkspaceSidebarAction,
@@ -3290,6 +3461,7 @@ function WorkspaceScreenContent({
function renderSplitPaneEmptyState(input: { function renderSplitPaneEmptyState(input: {
paneId: string; paneId: string;
paneIsolated: boolean; paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void; onClosePane: () => void;
}) { }) {
// Same predicate the close action reads, so the button is offered exactly when // Same predicate the close action reads, so the button is offered exactly when
@@ -3301,6 +3473,7 @@ function WorkspaceScreenContent({
return ( return (
<WorkspacePaneEmptyState <WorkspacePaneEmptyState
paneId={input.paneId} paneId={input.paneId}
isFocused={input.isFocused}
showChanges={isGitCheckout} showChanges={isGitCheckout}
showPullRequest={hasPullRequest} showPullRequest={hasPullRequest}
onOpenView={handleOpenPaneView} onOpenView={handleOpenPaneView}
@@ -1582,7 +1582,12 @@ export function replaceTabTargetInLayout(
}; };
} }
let tabId = input.createTabId(); let tabId = input.createTabId();
if (currentTab?.target.kind === "draft") { // 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 === "draft" ||
(currentTab?.target.kind === "file" && input.target.kind === "file")
) {
tabId = input.tabId; tabId = input.tabId;
} else if (input.target.kind === "draft") { } else if (input.target.kind === "draft") {
tabId = buildDeterministicWorkspaceTabId(input.target); tabId = buildDeterministicWorkspaceTabId(input.target);
@@ -459,6 +459,7 @@ describe("workspace-layout-store actions", () => {
const fileState: FileState = { treeVisible: true, treeWidth: 240 }; const fileState: FileState = { treeVisible: true, treeWidth: 240 };
store.setTabState(workspaceKey, file, fileState); store.setTabState(workspaceKey, file, fileState);
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" }); const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
expect(sameKind).toBe(file);
expect( expect(
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find( collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
(tab) => tab.tabId === sameKind, (tab) => tab.tabId === sameKind,
@@ -554,11 +555,9 @@ describe("workspace-layout-store actions", () => {
{ kind: "file", path: "/repo/b.ts" }, { kind: "file", path: "/repo/b.ts" },
{ ...defaultFileState, treeVisible: true, treeWidth: 300 }, { ...defaultFileState, treeVisible: true, treeWidth: 300 },
); );
expect(replacement).not.toBe(file); expect(replacement).toBe(file);
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined(); expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent);
expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent);
expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file);
store.closeTab(workspaceKey, replacement as string); store.closeTab(workspaceKey, replacement as string);
expect( expect(
@@ -31,6 +31,7 @@ import {
openSupportingTab, openSupportingTab,
showSidePanel, showSidePanel,
toggleSidePanel, toggleSidePanel,
toggleSupportingTab,
} from "@/workspace-tabs/side-panel"; } from "@/workspace-tabs/side-panel";
const WORKSPACE_KEY = "server-1:ws-main"; const WORKSPACE_KEY = "server-1:ws-main";
@@ -205,6 +206,27 @@ describe("non-compact with splits", () => {
expect(isSidePanelOpen(wide)).toBe(false); expect(isSidePanelOpen(wide)).toBe(false);
expect(tabKinds()).toEqual(["files"]); expect(tabKinds()).toEqual(["files"]);
}); });
it("toggles the visible supporting target without closing its tab", () => {
const changes = {
...wide,
target: { kind: "working_diff" } as const,
openInSidePanelByDefault: true,
};
const tabId = toggleSupportingTab(changes);
openSidePanelView({ ...wide, view: "files" });
toggleSupportingTab(changes);
expect(isSidePanelOpen(wide)).toBe(true);
expect(focusedPaneTabKinds()).toContain("working_diff");
toggleSupportingTab(changes);
expect(isSidePanelOpen(wide)).toBe(false);
expect(tabKinds()).toContain("working_diff");
expect(toggleSupportingTab(changes)).toBe(tabId);
expect(isSidePanelOpen(wide)).toBe(true);
});
}); });
describe("non-compact without splits", () => { describe("non-compact without splits", () => {
@@ -14,6 +14,7 @@ import {
type WorkspaceTabPlacement, type WorkspaceTabPlacement,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
/** /**
* The side panel is the companion surface beside the user's work — Changes, Files, * The side panel is the companion surface beside the user's work — Changes, Files,
@@ -62,6 +63,20 @@ function canUseSidePanelPane(input: SidePanelQuery): boolean {
return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits()); return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits());
} }
function isTargetVisibleInSidePanel(
state: LayoutState,
workspaceKey: string,
target: WorkspaceTabTarget,
): boolean {
if (!selectIsSidePanelVisible(state, workspaceKey)) return false;
const layout = state.layoutByWorkspace[workspaceKey];
const paneId = selectSidePanelPaneId(state, workspaceKey);
const focusedTabId = layout && paneId ? findPaneById(layout.root, paneId)?.focusedTabId : null;
if (!layout || !focusedTabId) return false;
const focusedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === focusedTabId);
return Boolean(focusedTab && workspaceTabTargetsEqual(focusedTab.target, target));
}
// ─── Side panel tabs (non-compact, no splits) ────────────────────────────── // ─── Side panel tabs (non-compact, no splits) ──────────────────────────────
/** /**
@@ -92,6 +107,11 @@ export interface OpenSupportingTabInput extends SidePanelQuery {
background?: boolean; background?: boolean;
} }
export interface ToggleSupportingTabInput extends SidePanelQuery {
target: WorkspaceTabTarget;
openInSidePanelByDefault: boolean;
}
/** /**
* Opens a tab the user did not place themselves — an agent's file link, a detected * Opens a tab the user did not place themselves — an agent's file link, a detected
* pull request, workspace setup progress. A supporting tab that is already open is * pull request, workspace setup progress. A supporting tab that is already open is
@@ -123,6 +143,52 @@ 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.workspaceKey) return null;
const store = useWorkspaceLayoutStore.getState();
if (
input.openInSidePanelByDefault &&
canUseSidePanelPane(input) &&
isTargetVisibleInSidePanel(store, input.workspaceKey, input.target)
) {
store.hideSidePanel(input.workspaceKey);
return null;
}
return openSupportingTab(input);
}
/** Explicitly place a companion tab in the Side panel, moving an existing tab there. */
export function openTabInSidePanel(
input: SidePanelInput & { target: WorkspaceTabTarget },
): string | null {
if (!input.workspaceKey) {
return null;
}
if (input.isCompact) {
const view = Object.entries(SIDE_PANEL_VIEW_TARGETS).find(
([, target]) => target.kind === input.target.kind,
)?.[0] as ExplorerTab | undefined;
if (!view || !input.checkout) {
return null;
}
const panel = usePanelStore.getState();
panel.setExplorerTabForCheckout({ ...input.checkout, tab: view });
panel.openCompactFileExplorer(input.checkout);
return null;
}
const store = useWorkspaceLayoutStore.getState();
const paneId = canUseSidePanelPane(input) ? store.showSidePanel(input.workspaceKey) : undefined;
return store.openTab({
workspaceKey: input.workspaceKey,
target: input.target,
intent: "reveal",
placement: paneId ? { mode: "pane", paneId } : undefined,
});
}
/** /**
* Undefined means "no opinion, use the focused pane". Revealing is part of resolving * Undefined means "no opinion, use the focused pane". Revealing is part of resolving
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a * the side panel: a focused tab in a hidden pane is nothing the user can see, so a