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
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:
+3
-1
@@ -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} />
|
||||
|
||||
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
|
||||
workspaces: "مساحات العمل",
|
||||
agents: "الوكلاء",
|
||||
newAgent: "وكيل جديد",
|
||||
open: "فتح {{name}}",
|
||||
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
|
||||
openInFocusedPane: "فتح {{name}} في الجزء النشط",
|
||||
addProject: "إضافة مشروع",
|
||||
home: "بيت",
|
||||
groupByProject: "تجميع حسب المشروع",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
|
||||
workspaces: "ワークスペース",
|
||||
agents: "エージェント",
|
||||
newAgent: "新しいエージェント",
|
||||
open: "{{name}}を開く",
|
||||
openInSidePanel: "{{name}}をサイドパネルで開く",
|
||||
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
|
||||
addProject: "プロジェクトを追加",
|
||||
home: "ホーム",
|
||||
groupByProject: "プロジェクトでグループ化",
|
||||
|
||||
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
|
||||
workspaces: "워크스페이스",
|
||||
agents: "에이전트",
|
||||
newAgent: "새 에이전트",
|
||||
open: "{{name}} 열기",
|
||||
openInSidePanel: "사이드 패널에서 {{name}} 열기",
|
||||
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
|
||||
addProject: "프로젝트 추가",
|
||||
home: "홈",
|
||||
groupByProject: "프로젝트별 그룹화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
|
||||
workspaces: "Рабочие пространства",
|
||||
agents: "Агенты",
|
||||
newAgent: "Новый агент",
|
||||
open: "Открыть {{name}}",
|
||||
openInSidePanel: "Открыть {{name}} в боковой панели",
|
||||
openInFocusedPane: "Открыть {{name}} в активной области",
|
||||
addProject: "Добавить проект",
|
||||
home: "Главная",
|
||||
groupByProject: "Группировать по проекту",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user