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
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
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
@@ -2,6 +2,7 @@ import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
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-");
try {
@@ -58,6 +59,26 @@ test("composer diff stat opens Changes in the preferred Side panel", async ({ pa
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 {
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,
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) => {
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
const sample = () => {
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
await expect(page.getByTestId("file-preview-unsupported")).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(
() =>
(
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
newBrowser: "New browser",
splitRight: "Split pane right",
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: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
},
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
{ id: "tab:new-agent", rank: 0, visibility: "always" },
{ id: "git:commit", rank: 1, visibility: "always" },
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
{ id: "tab:new-browser", rank: 3, visibility: "query" },
{ id: "pane:split-right", rank: 4, visibility: "query" },
{ id: "pane:split-down", rank: 5, visibility: "query" },
expect(
contributions
.filter((item) =>
[
"tab:new-agent",
"git:commit",
"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", () => {
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);
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", () => {
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched).toEqual([
{ id: "workspace.agent.new", scope: "workspace" },
{ 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" },
]);
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.map((item) => item.id)).toEqual([
"tab:new-agent",
"tab:new-terminal",
"tab:new-browser",
"pane:split-right",
"pane:split-down",
]);
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(true);
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
});
@@ -1,5 +1,10 @@
import type { GitAction, GitActions } from "@/git/policy";
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 { CommandCenterContribution, CommandCenterIcon } from "./contributions";
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
newBrowser: string;
splitRight: 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 {
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
newBrowser?: CommandCenterIcon;
splitRight?: 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;
}
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
splitRight?: ShortcutKey[][];
splitDown?: ShortcutKey[][];
archiveWorkspace?: ShortcutKey[][];
previousTab?: ShortcutKey[][];
nextTab?: ShortcutKey[][];
closeCurrentTab?: ShortcutKey[][];
closePane?: ShortcutKey[][];
togglePaneMaximization?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleSidePanel?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
capabilities: {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
dispatch(action: KeyboardActionDefinition): 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,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
const primary = source.gitActions.primary;
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-terminal",
rank: 2,
title: source.labels.newTerminal,
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
icon: source.icons.newTerminal,
shortcutKeys: source.shortcuts.newTerminal,
action: { id: "workspace.terminal.new", scope: "workspace" },
visibility: "query",
}),
);
if (source.capabilities.canOpenBrowserTabs) {
contributions.push(
buildWorkspaceAction({
source,
buildQueryAction(source, {
id: "tab:new-browser",
rank: 3,
title: source.labels.newBrowser,
keywords: ["browser", "web", "preview"],
icon: source.icons.newBrowser,
action: { id: "workspace.browser.new", scope: "workspace" },
visibility: "query",
}),
);
}
if (source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "pane:split-right",
rank: 4,
title: source.labels.splitRight,
keywords: ["split", "pane", "vertical"],
icon: source.icons.splitRight,
shortcutKeys: source.shortcuts.splitRight,
action: { id: "workspace.pane.split.right", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceAction({
source,
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",
return contributions;
}
export function buildWorkspaceCommandCenterContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
const contributions = [
...buildCreationContributions(source),
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
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" },
}),
);
}
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
return contributions;
}
@@ -1,6 +1,26 @@
import { useMemo, type ReactElement } from "react";
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 { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
@@ -13,6 +33,12 @@ import {
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
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 { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon";
@@ -29,8 +55,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
newBrowser: getCommandCenterIcon(Globe),
splitRight: getCommandCenterIcon(Columns2),
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 {
if (!element) return undefined;
function StaticIcon() {
@@ -51,6 +97,17 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
archiveWorkspace:
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 serverId = selection?.serverId ?? 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 isCompact = useIsCompactFormFactor();
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions } = useGitActions({
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
@@ -81,6 +150,34 @@ export function useWorkspaceCommandCenterActions(): void {
newBrowser: t("workspace.tabs.actions.newBrowser"),
splitRight: t("workspace.tabs.actions.splitRight"),
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: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -90,14 +187,29 @@ export function useWorkspaceCommandCenterActions(): void {
capabilities: {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
}),
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
[
activeTabIndex,
activeTabKind,
focusedTabs.length,
gitActions,
isCompact,
isGit,
keyboardActionDispatcher,
overrides,
runGitAction,
t,
],
);
useCommandCenterActions({
@@ -135,6 +135,7 @@ interface SplitContainerProps {
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
@@ -1289,6 +1290,7 @@ function SplitPaneView({
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
isFocused: isFocused && isWorkspaceFocused,
onClosePane: handleClosePane,
}) ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
+3
View File
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
open: "فتح {{name}}",
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
openInFocusedPane: "فتح {{name}} في الجزء النشط",
addProject: "إضافة مشروع",
home: "بيت",
groupByProject: "تجميع حسب المشروع",
+3
View File
@@ -61,6 +61,9 @@ export const en = {
workspaces: "Workspaces",
agents: "Agents",
newAgent: "New agent",
open: "Open {{name}}",
openInSidePanel: "Open {{name}} in Side panel",
openInFocusedPane: "Open {{name}} in focused pane",
addProject: "Add project",
home: "Home",
groupByProject: "Group by project",
+3
View File
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
workspaces: "Espacios de trabajo",
agents: "Agentes",
newAgent: "Nuevo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} en el panel lateral",
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
addProject: "Agregar proyecto",
home: "Hogar",
groupByProject: "Agrupar por proyecto",
+3
View File
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
workspaces: "Espaces de travail",
agents: "Agents",
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",
home: "Maison",
groupByProject: "Grouper par projet",
+3
View File
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
open: "{{name}}を開く",
openInSidePanel: "{{name}}をサイドパネルで開く",
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
addProject: "プロジェクトを追加",
home: "ホーム",
groupByProject: "プロジェクトでグループ化",
+3
View File
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
workspaces: "워크스페이스",
agents: "에이전트",
newAgent: "새 에이전트",
open: "{{name}} 열기",
openInSidePanel: "사이드 패널에서 {{name}} 열기",
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
addProject: "프로젝트 추가",
home: "홈",
groupByProject: "프로젝트별 그룹화",
+3
View File
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
workspaces: "Espaços de trabalho",
agents: "Agentes",
newAgent: "Novo agente",
open: "Abrir {{name}}",
openInSidePanel: "Abrir {{name}} no painel lateral",
openInFocusedPane: "Abrir {{name}} no painel em foco",
addProject: "Adicionar projeto",
home: "Início",
groupByProject: "Agrupar por projeto",
+3
View File
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
open: "Открыть {{name}}",
openInSidePanel: "Открыть {{name}} в боковой панели",
openInFocusedPane: "Открыть {{name}} в активной области",
addProject: "Добавить проект",
home: "Главная",
groupByProject: "Группировать по проекту",
+3
View File
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
open: "打开{{name}}",
openInSidePanel: "在侧边面板中打开{{name}}",
openInFocusedPane: "在当前窗格中打开{{name}}",
addProject: "添加 project",
home: "首页",
groupByProject: "按项目分组",
@@ -1,5 +1,8 @@
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 =
| "agent.interrupt"
| "message-input.focus"
@@ -19,6 +22,15 @@ export type KeyboardActionId =
| "workspace.tab.close-current"
| "workspace.tab.navigate-index"
| "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.down"
| "workspace.pane.focus.left"
@@ -61,6 +73,20 @@ export type KeyboardActionDefinition =
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { 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.down"; 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 { navigateToAgent } from "@/utils/navigate-to-agent";
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
@@ -96,7 +96,7 @@ export const AgentTracks = memo(function AgentTracks({
if (!workspaceKey) {
return;
}
openSupportingTab({
toggleSupportingTab({
isCompact,
workspaceKey,
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 { useTranslation } from "react-i18next";
import {
@@ -11,6 +11,7 @@ import {
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";
@@ -31,6 +32,7 @@ const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMappin
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 {
@@ -38,8 +40,19 @@ function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | nu
return keys ? <Shortcut chord={keys} /> : null;
}
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) {
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
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,
];
}
/**
@@ -62,6 +75,8 @@ function LauncherRow({
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
dataSet={LAUNCHER_ROW_DATA_SET}
tabIndex={-1}
onPress={onPress}
style={getLauncherRowStyle}
>
@@ -75,6 +90,8 @@ function LauncherRow({
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;
@@ -91,6 +108,7 @@ export interface WorkspacePaneEmptyStateProps {
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
paneId,
isFocused,
showChanges,
showPullRequest,
onOpenView,
@@ -99,6 +117,42 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
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 }),
@@ -119,7 +173,7 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
);
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.stack}>
{showChanges ? (
@@ -196,11 +250,18 @@ const styles = StyleSheet.create((theme) => ({
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,
},
@@ -50,6 +50,7 @@ import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar
import {
isSidePanelOpen,
openSupportingTab,
openTabInSidePanel,
toggleSidePanel,
useIsSidePanelOpen,
} from "@/workspace-tabs/side-panel";
@@ -76,7 +77,10 @@ import {
import { useSettings } from "@/hooks/use-settings";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
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 {
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({
serverId,
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(
(action: KeyboardActionDefinition): boolean => {
switch (action.id) {
@@ -2999,6 +3117,10 @@ function WorkspaceScreenContent({
],
);
const workspaceKeyboardActionsEnabled = Boolean(
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
);
useKeyboardActionHandler({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-tab-actions",
@@ -3013,12 +3135,61 @@ function WorkspaceScreenContent({
"workspace.terminal.new",
"workspace.browser.new",
] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
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({
handlerId: buildWorkspaceKeyboardHandlerId({
name: "workspace-pane-actions",
@@ -3039,7 +3210,7 @@ function WorkspaceScreenContent({
"workspace.pane.close",
"workspace.focus.toggle",
] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePaneAction,
@@ -3052,7 +3223,7 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceSidebarAction,
@@ -3290,6 +3461,7 @@ function WorkspaceScreenContent({
function renderSplitPaneEmptyState(input: {
paneId: string;
paneIsolated: boolean;
isFocused: boolean;
onClosePane: () => void;
}) {
// Same predicate the close action reads, so the button is offered exactly when
@@ -3301,6 +3473,7 @@ function WorkspaceScreenContent({
return (
<WorkspacePaneEmptyState
paneId={input.paneId}
isFocused={input.isFocused}
showChanges={isGitCheckout}
showPullRequest={hasPullRequest}
onOpenView={handleOpenPaneView}
@@ -1582,7 +1582,12 @@ export function replaceTabTargetInLayout(
};
}
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;
} else if (input.target.kind === "draft") {
tabId = buildDeterministicWorkspaceTabId(input.target);
@@ -459,6 +459,7 @@ describe("workspace-layout-store actions", () => {
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
store.setTabState(workspaceKey, file, fileState);
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
expect(sameKind).toBe(file);
expect(
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
(tab) => tab.tabId === sameKind,
@@ -554,11 +555,9 @@ describe("workspace-layout-store actions", () => {
{ kind: "file", path: "/repo/b.ts" },
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
);
expect(replacement).not.toBe(file);
expect(replacement).toBe(file);
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined();
expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent);
expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file);
expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent);
store.closeTab(workspaceKey, replacement as string);
expect(
@@ -31,6 +31,7 @@ import {
openSupportingTab,
showSidePanel,
toggleSidePanel,
toggleSupportingTab,
} from "@/workspace-tabs/side-panel";
const WORKSPACE_KEY = "server-1:ws-main";
@@ -205,6 +206,27 @@ describe("non-compact with splits", () => {
expect(isSidePanelOpen(wide)).toBe(false);
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", () => {
@@ -14,6 +14,7 @@ import {
type WorkspaceTabPlacement,
} from "@/stores/workspace-layout-store";
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,
@@ -62,6 +63,20 @@ function canUseSidePanelPane(input: SidePanelQuery): boolean {
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) ──────────────────────────────
/**
@@ -92,6 +107,11 @@ export interface OpenSupportingTabInput extends SidePanelQuery {
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
* 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
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a