diff --git a/docs/side-panel.md b/docs/side-panel.md index 98ba4166f..2fc113ba0 100644 --- a/docs/side-panel.md +++ b/docs/side-panel.md @@ -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 diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index ee17cb992..e8ff9302d 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -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(); } diff --git a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts new file mode 100644 index 000000000..f0a32857c --- /dev/null +++ b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts @@ -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 { + await expect(launcher.getByRole("button", { name })).toBeFocused(); +} + +async function openSidePanelWithKeyboard(page: Page): Promise { + 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(); + } + }); +}); diff --git a/packages/app/e2e/browser/file-transition.spec.ts b/packages/app/e2e/browser/file-transition.spec.ts index 8235ce057..574f9baa8 100644 --- a/packages/app/e2e/browser/file-transition.spec.ts +++ b/packages/app/e2e/browser/file-transition.spec.ts @@ -39,6 +39,12 @@ async function assertHeldFileTransition( finalText: string, gate: Awaited>, ) { + 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( () => ( diff --git a/packages/app/src/command-center/workspace-contributions.test.ts b/packages/app/src/command-center/workspace-contributions.test.ts index 5dfa82e0e..cfaf3a8e5 100644 --- a/packages/app/src/command-center/workspace-contributions.test.ts +++ b/packages/app/src/command-center/workspace-contributions.test.ts @@ -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); }); }); diff --git a/packages/app/src/command-center/workspace-contributions.ts b/packages/app/src/command-center/workspace-contributions.ts index aa743f2e3..4f1c108ff 100644 --- a/packages/app/src/command-center/workspace-contributions.ts +++ b/packages/app/src/command-center/workspace-contributions.ts @@ -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[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; } diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index b6f867eb1..0f16117b8 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -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({ diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 995002f9c..269c7ff78 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -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)} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 629043b31..d8b97fd35 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -64,6 +64,9 @@ export const ar: TranslationResources = { workspaces: "مساحات العمل", agents: "الوكلاء", newAgent: "وكيل جديد", + open: "فتح {{name}}", + openInSidePanel: "فتح {{name}} في اللوحة الجانبية", + openInFocusedPane: "فتح {{name}} في الجزء النشط", addProject: "إضافة مشروع", home: "بيت", groupByProject: "تجميع حسب المشروع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a28f938c1..c37ac6ea2 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 652258ff9..ceb0c259b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 464abb814..7155aaabd 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0c3b64c9d..00d771edc 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -64,6 +64,9 @@ export const ja: TranslationResources = { workspaces: "ワークスペース", agents: "エージェント", newAgent: "新しいエージェント", + open: "{{name}}を開く", + openInSidePanel: "{{name}}をサイドパネルで開く", + openInFocusedPane: "{{name}}をフォーカス中のペインで開く", addProject: "プロジェクトを追加", home: "ホーム", groupByProject: "プロジェクトでグループ化", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6c328acdf..389ee18e0 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -64,6 +64,9 @@ export const ko: TranslationResources = { workspaces: "워크스페이스", agents: "에이전트", newAgent: "새 에이전트", + open: "{{name}} 열기", + openInSidePanel: "사이드 패널에서 {{name}} 열기", + openInFocusedPane: "포커스된 창에서 {{name}} 열기", addProject: "프로젝트 추가", home: "홈", groupByProject: "프로젝트별 그룹화", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a708929f5..ad4a1f8bc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ac35a6674..9f3b91aae 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -64,6 +64,9 @@ export const ru: TranslationResources = { workspaces: "Рабочие пространства", agents: "Агенты", newAgent: "Новый агент", + open: "Открыть {{name}}", + openInSidePanel: "Открыть {{name}} в боковой панели", + openInFocusedPane: "Открыть {{name}} в активной области", addProject: "Добавить проект", home: "Главная", groupByProject: "Группировать по проекту", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 98c53402c..ff1a43087 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -64,6 +64,9 @@ export const zhCN: TranslationResources = { workspaces: "工作区", agents: "Agents", newAgent: "新建 Agent", + open: "打开{{name}}", + openInSidePanel: "在侧边面板中打开{{name}}", + openInFocusedPane: "在当前窗格中打开{{name}}", addProject: "添加 project", home: "首页", groupByProject: "按项目分组", diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index cf0ff2267..376fc1755 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -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 } diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 198e1e46e..7c04edf24 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -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" }, diff --git a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx b/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx index e6f59168a..ba040709f 100644 --- a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx @@ -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 = : 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({ @@ -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(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(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(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 ( - + {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, }, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 14fde0900..a642b7d6f 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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 ( { 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( diff --git a/packages/app/src/workspace-tabs/side-panel.test.ts b/packages/app/src/workspace-tabs/side-panel.test.ts index a874e1b8a..b5b763769 100644 --- a/packages/app/src/workspace-tabs/side-panel.test.ts +++ b/packages/app/src/workspace-tabs/side-panel.test.ts @@ -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", () => { diff --git a/packages/app/src/workspace-tabs/side-panel.ts b/packages/app/src/workspace-tabs/side-panel.ts index e77fd5ee7..e2f29020a 100644 --- a/packages/app/src/workspace-tabs/side-panel.ts +++ b/packages/app/src/workspace-tabs/side-panel.ts @@ -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