feat(app): add workspace actions to the command center (#3013)

Eight actions were only reachable through the sidebar workspace menu,
the workspace header menu, or a keybind you had to remember: Rename
workspace, Copy workspace path, Copy branch name, Pin/Unpin, the three
layout toggles, and Show setup. All eight are now in the command
center, plus a new one: label assignment. Type a label's name to
assign or unassign it on the active workspace without leaving the
keyboard.

Rename needed more than a palette entry. Its dialog lived inside the
sidebar row component, so it disappeared whenever that row was not
rendered: a collapsed project or status group, a collapsed Pinned
section, or focus mode. It now has a global host keyed on the active
workspace selection, mirroring use-global-workspace-pin-action.ts.

Each entry reuses the handler the existing menu item already calls, so
behaviour matches the menu. Entries that cannot act are omitted rather
than present and failing: Copy branch name without a branch, Pin when
the host lacks workspacePinning, Show setup when there is nothing to
show, and labels when the catalog has not loaded.

Toggle Explorer sidebar and Toggle focus mode are registered as
workspace contributions, not global ones. Their handlers live in
workspace-screen.tsx behind an isRouteFocused gate, so a global
registration would have listed two entries that close the palette and
do nothing on /settings, /sessions, /schedules and Home.

The copy-path, copy-branch, and rename-dialog logic was duplicated
across three sidebar files. It moves into one hook and one component so
the palette is not a fourth copy. That also fixes drift in
sidebar-status-list.tsx, where Copy branch name copied the workspace
name instead of the branch and four strings were hardcoded English.
This supersedes #3383, which fixes the same bug directly without the
shared-hook refactor.

Toggle focus mode also had a hidden duplicate: buildPaneContributions
already dispatches the same action once split panes are available, so
the standalone entry now steps aside there instead of listing the
toggle twice.

No new i18n keys.
This commit is contained in:
Christoph Leiter
2026-08-28 17:43:46 +02:00
committed by GitHub
parent 667664847d
commit ed628ff82e
16 changed files with 1178 additions and 168 deletions
@@ -0,0 +1,270 @@
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
function workspaceRow(page: Page, workspaceId: string): Locator {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
function action(panel: Locator, title: string): Locator {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
}
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
const row = workspaceRow(page, workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
// that used to make the sidebar-owned rename dialog unreachable.
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: project.projectDisplayName });
await expect(header).toHaveCount(1, { timeout: 30_000 });
await header.click();
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
}
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
// surface under test anyway, and it works from every route.
async function openCommandCenter(page: Page): Promise<Locator> {
await page.keyboard.press("ControlOrMeta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
async function runCommand(page: Page, query: string, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(query);
const entry = action(panel, title);
await expect(entry).toBeVisible({ timeout: 15_000 });
await entry.click();
}
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
const workspaces = await workspace.client.fetchWorkspaces();
for (const entry of workspaces.entries) {
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
}
return undefined;
}
async function rejectNextLabelAssignment(page: Page): Promise<void> {
let rejectNext = true;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const raw = typeof message === "string" ? message : message.toString("utf8");
let request: { type?: string; requestId?: string } | null = null;
try {
const envelope = JSON.parse(raw) as {
type?: string;
message?: { type?: string; requestId?: string };
};
request = envelope.type === "session" ? (envelope.message ?? null) : null;
} catch {
server.send(message);
return;
}
if (
rejectNext &&
request?.type === "workspace.label.assignment.set.request" &&
request.requestId
) {
rejectNext = false;
browser.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: "Injected label mutation failure.",
code: "handler_error",
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
}
test.describe("Command center workspace management", () => {
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await collapseProjectSection(page, workspace);
await runCommand(page, "rename", "Rename workspace");
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
// focus race with its focus-restore, still renders — you just cannot type into it.
const input = page.getByTestId("workspace-rename-modal-global-input");
await expect(input).toBeFocused({ timeout: 15_000 });
await expect(input).toHaveValue(workspace.workspaceName);
const customTitle = "Renamed From Palette";
await input.fill(customTitle);
await page.getByTestId("workspace-rename-modal-global-submit").click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
// Re-expand the section to read the row back.
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: workspace.projectDisplayName });
await header.click();
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("copies the workspace path and confirms with a toast", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "copy path", "Copy workspace path");
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "pin", "Pin to top");
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("pin");
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Pin to top")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
// off a workspace route would give the user two entries that close the palette and do nothing.
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
// All three are listed while a workspace route owns the handlers.
const workspacePanel = await openCommandCenter(page);
await workspacePanel.getByTestId("command-center-input").fill("toggle");
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("toggle");
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("toggles a label on the active workspace from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
try {
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
// Unassigning the only label drops the field entirely rather than leaving an empty array.
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
const reopened = await openCommandCenter(page);
await reopened.getByTestId("command-center-input").fill("urgent");
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
test("shows an error toast when assigning a label fails", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
try {
await rejectNextLabelAssignment(page);
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
await expect(page.getByTestId("app-toast-message")).toContainText(
"Injected label mutation failure.",
);
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
});
+2
View File
@@ -39,6 +39,7 @@ import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
@@ -595,6 +596,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -9,17 +9,19 @@ import {
History,
Home,
Keyboard,
PanelLeft,
Plus,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
@@ -54,6 +56,9 @@ const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.for
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedPanelLeft = withUnistyles(PanelLeft, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
@@ -91,6 +96,10 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) {
return <ThemedCircleDashed size={size} strokeWidth={2.2} />;
}
function PanelLeftIcon({ size }: CommandCenterIconProps) {
return <ThemedPanelLeft size={size} strokeWidth={2.2} />;
}
export function CommandCenterRootActions() {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -106,6 +115,10 @@ export function CommandCenterRootActions() {
// each time host filters are reconciled.
const groupMode = useSidebarViewStore((state) => state.groupMode);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const isCompact = useIsCompactFormFactor();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
const shortcutPlatform = useMemo(
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[],
@@ -227,6 +240,33 @@ export function CommandCenterRootActions() {
undefined,
},
},
// Toggle left sidebar is global: it calls the panel store directly and works on every route.
// The right sidebar and focus toggles do NOT belong here — their handlers live in
// workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so registering them globally
// would list entries that silently no-op off a workspace route. They live in
// workspace-contributions.ts instead. That is why the three toggles render in two
// non-adjacent sections; don't "tidy" them back together.
{
id: "toggle-left-sidebar",
group: "actions",
groupRank: 0,
rank: 7,
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
visibility: "query",
run: () => {
clearCommandCenterFocusRestoreElement();
toggleAgentList();
},
presentation: {
kind: "action",
title: t("settings.shortcuts.help.toggleLeftSidebar"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PanelLeftIcon,
shortcutKeys:
resolveShortcutKeysForAction("toggle-left-sidebar", overrides, shortcutPlatform) ??
undefined,
},
},
];
if (shortcutsAvailable) {
@@ -278,6 +318,7 @@ export function CommandCenterRootActions() {
shortcutPlatform,
shortcutsAvailable,
t,
toggleAgentList,
]);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
@@ -23,9 +23,14 @@ function source(gitActions: GitActions): {
value: WorkspaceCommandCenterSource;
runGitActions: GitAction[];
dispatched: KeyboardActionDefinition[];
copiedPaths: number;
copiedBranchNames: number;
toggledLabels: Array<{ name: string; assigned: boolean }>;
} {
const runGitActions: GitAction[] = [];
const dispatched: KeyboardActionDefinition[] = [];
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
return {
value: {
gitActions,
@@ -62,19 +67,51 @@ function source(gitActions: GitActions): {
moveTabDown: "Move tab down",
closePane: "Close pane",
toggleFocusMode: "Toggle focus mode",
toggleExplorerSidebar: "Toggle side panel",
toggleExplorerSidebar: "Toggle Explorer sidebar",
rename: "Rename workspace",
copyPath: "Copy workspace path",
copyBranchName: "Copy branch name",
pin: "Pin to top",
unpin: "Unpin",
showSetup: "Show setup",
labelsGroup: "Labels",
},
icons: {},
shortcuts: {},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
capabilities: {
canSplitPanes: true,
canOpenBrowserTabs: true,
isGit: false,
canPin: false,
canShowSetup: false,
},
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
currentBranch: null,
isPinned: false,
labelCatalog: null,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
copyPath: () => {
counters.copiedPaths += 1;
},
copyBranchName: () => {
counters.copiedBranchNames += 1;
},
toggleLabel: (name, assigned) => {
toggledLabels.push({ name, assigned });
},
},
runGitActions,
dispatched,
toggledLabels,
get copiedPaths() {
return counters.copiedPaths;
},
get copiedBranchNames() {
return counters.copiedBranchNames;
},
};
}
@@ -147,6 +184,8 @@ describe("workspace command center contributions", () => {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
canPin: false,
canShowSetup: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
@@ -155,6 +194,8 @@ describe("workspace command center contributions", () => {
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);
expect(contributions.some((item) => item.id === "workspace:rename")).toBe(true);
expect(contributions.some((item) => item.id === "workspace:copy-path")).toBe(true);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -164,7 +205,14 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched.length).toBeGreaterThan(5);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
// workspace:rename dispatches to workspace scope
expect(
fixture.dispatched.some((a) => a.id === "workspace.rename" && a.scope === "workspace"),
).toBe(true);
// workspace:toggle-explorer-sidebar intentionally dispatches to sidebar scope
expect(
fixture.dispatched.some((a) => a.id === "sidebar.toggle.right" && a.scope === "sidebar"),
).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -179,4 +227,164 @@ describe("workspace command center contributions", () => {
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
it("dispatches rename to the workspace scope", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const rename = contributions.find((item) => item.id === "workspace:rename");
expect(rename?.presentation).toMatchObject({ title: "Rename workspace" });
rename?.run();
expect(fixture.dispatched).toEqual([{ id: "workspace.rename", scope: "workspace" }]);
});
it("copies the workspace path without going through the dispatcher", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:copy-path")?.run();
expect(fixture.copiedPaths).toBe(1);
expect(fixture.dispatched).toEqual([]);
});
it("omits Copy branch name when the workspace has no branch, and copies when it does", () => {
const withoutBranch = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutBranch.value).some(
(item) => item.id === "workspace:copy-branch-name",
),
).toBe(false);
const withBranch = source({ primary: null, secondary: [], menu: [] });
withBranch.value.currentBranch = "feature/login";
const contribution = buildWorkspaceCommandCenterContributions(withBranch.value).find(
(item) => item.id === "workspace:copy-branch-name",
);
expect(contribution?.keywords).toContain("feature/login");
contribution?.run();
expect(withBranch.copiedBranchNames).toBe(1);
});
it("flips the pin label on pinned state and omits the entry when the host cannot pin", () => {
const unpinned = source({ primary: null, secondary: [], menu: [] });
unpinned.value.capabilities.canPin = true;
const pinContribution = buildWorkspaceCommandCenterContributions(unpinned.value).find(
(item) => item.id === "workspace:pin",
);
expect(pinContribution?.presentation).toMatchObject({ title: "Pin to top" });
expect(pinContribution?.visibility).toBe("always");
pinContribution?.run();
expect(unpinned.dispatched).toEqual([{ id: "workspace.pin", scope: "sidebar" }]);
const pinned = source({ primary: null, secondary: [], menu: [] });
pinned.value.capabilities.canPin = true;
pinned.value.isPinned = true;
expect(
buildWorkspaceCommandCenterContributions(pinned.value).find(
(item) => item.id === "workspace:pin",
)?.presentation,
).toMatchObject({ title: "Unpin" });
const unsupported = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(unsupported.value).some(
(item) => item.id === "workspace:pin",
),
).toBe(false);
});
it("lists Show setup only when the workspace has setup to show", () => {
const withoutSetup = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutSetup.value).some(
(item) => item.id === "workspace:show-setup",
),
).toBe(false);
const withSetup = source({ primary: null, secondary: [], menu: [] });
withSetup.value.capabilities.canShowSetup = true;
buildWorkspaceCommandCenterContributions(withSetup.value)
.find((item) => item.id === "workspace:show-setup")
?.run();
expect(withSetup.dispatched).toEqual([{ id: "workspace.setup.show", scope: "workspace" }]);
});
// Regression guard for the registration split: these two are handled only by workspace-screen.tsx
// behind `enabled: isRouteFocused && ...`, so they must be built here rather than in the global
// root set, where they would silently no-op off a workspace route.
it("builds the Explorer sidebar and focus toggles in the workspace set", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = false;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:toggle-explorer-sidebar")?.run();
contributions.find((item) => item.id === "workspace:toggle-focus-mode")?.run();
expect(fixture.dispatched).toEqual([
{ id: "sidebar.toggle.right", scope: "sidebar" },
{ id: "workspace.focus.toggle", scope: "workspace" },
]);
for (const contribution of contributions) {
expect(contribution.group).toBe("workspace");
}
});
// `buildPaneContributions` dispatches this same action as `pane:focus-mode-toggle` once split
// panes are available, so the standalone entry must step aside there — otherwise the palette
// lists "Toggle focus mode" twice for the one `workspace.focus.toggle` action.
it("omits the standalone focus toggle when the pane set already covers it", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = true;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const focusToggles = contributions.filter(
(item) =>
item.presentation.kind === "action" && item.presentation.title === "Toggle focus mode",
);
expect(focusToggles).toHaveLength(1);
expect(focusToggles[0]?.id).toBe("pane:focus-mode-toggle");
});
it("omits the labels group when the catalog hasn't loaded", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.some((item) => item.id.startsWith("workspace:label:"))).toBe(false);
});
it("lists a choice per catalog label, ticked when assigned, and toggles the opposite state on run", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.labelCatalog = [
{ name: "bug", assigned: true },
{ name: "urgent", assigned: false },
];
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const bug = contributions.find((item) => item.id === "workspace:label:bug");
const urgent = contributions.find((item) => item.id === "workspace:label:urgent");
expect(bug?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "bug"],
selected: true,
});
expect(urgent?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "urgent"],
selected: false,
});
bug?.run();
urgent?.run();
expect(fixture.toggledLabels).toEqual([
{ name: "bug", assigned: false },
{ name: "urgent", assigned: true },
]);
});
});
@@ -8,6 +8,13 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
export interface WorkspaceCommandCenterLabelChoice {
name: string;
/** Whether the current workspace already carries this label. */
assigned: boolean;
icon?: CommandCenterIcon;
}
export interface WorkspaceCommandCenterLabels {
section: string;
newAgent: string;
@@ -42,6 +49,14 @@ export interface WorkspaceCommandCenterLabels {
closePane: string;
toggleFocusMode: string;
toggleExplorerSidebar: string;
// Workspace management actions
rename: string;
copyPath: string;
copyBranchName: string;
pin: string;
unpin: string;
showSetup: string;
labelsGroup: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -63,6 +78,14 @@ export interface WorkspaceCommandCenterIcons {
moveTab?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
// Workspace management action icons
copyPath?: CommandCenterIcon;
copyBranchName?: CommandCenterIcon;
pin?: CommandCenterIcon;
unpin?: CommandCenterIcon;
showSetup?: CommandCenterIcon;
toggleFocusMode?: CommandCenterIcon;
label?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -78,6 +101,7 @@ export interface WorkspaceCommandCenterShortcuts {
closePane?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleExplorerSidebar?: ShortcutKey[][];
pinWorkspace?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -89,12 +113,27 @@ export interface WorkspaceCommandCenterSource {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
/** Host supports the `workspacePinning` feature. */
canPin: boolean;
/** The workspace has setup commands or a setup error — same gate as the header menu. */
canShowSetup: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
/** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */
currentBranch: string | null;
isPinned: boolean;
/**
* The host's label catalog, each entry told whether the current workspace carries it. Null
* before the catalog has loaded — omits the whole group rather than showing it empty.
*/
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
copyPath(): void;
copyBranchName(): void;
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
}
function buildGitContribution(
@@ -459,6 +498,35 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildWorkspaceCallback(input: {
source: WorkspaceCommandCenterSource;
id: string;
rank: number;
title: string;
keywords: readonly string[];
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
run: () => void;
visibility: "always" | "query";
}): CommandCenterContribution {
return {
id: input.id,
group: "workspace",
groupRank: -1,
rank: input.rank,
keywords: input.keywords,
visibility: input.visibility,
run: input.run,
presentation: {
kind: "action",
title: input.title,
sectionTitle: input.source.labels.section,
icon: input.icon,
shortcutKeys: input.shortcutKeys,
},
};
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
@@ -511,20 +579,150 @@ export function buildWorkspaceCommandCenterContributions(
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "explorer-sidebar:toggle",
rank: 70,
title: source.labels.toggleExplorerSidebar,
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
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, 100 + index, "query"));
}
if (source.capabilities.canPin) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:pin",
rank: 20,
title: source.isPinned ? source.labels.unpin : source.labels.pin,
keywords: ["pin", "unpin", "favorite", "sticky"],
icon: source.isPinned ? source.icons.unpin : source.icons.pin,
shortcutKeys: source.shortcuts.pinWorkspace,
action: { id: "workspace.pin", scope: "sidebar" },
visibility: "always",
}),
);
}
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:rename",
rank: 21,
title: source.labels.rename,
keywords: ["rename", "title", "name", "label"],
icon: source.icons.rename,
action: { id: "workspace.rename", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceCallback({
source,
id: "workspace:copy-path",
rank: 22,
title: source.labels.copyPath,
keywords: ["copy", "path", "directory", "folder", "cwd"],
icon: source.icons.copyPath,
run: source.copyPath,
visibility: "query",
}),
);
// Omitted rather than present-and-failing: a non-git workspace has nothing to copy.
if (source.currentBranch) {
contributions.push(
buildWorkspaceCallback({
source,
id: "workspace:copy-branch-name",
rank: 23,
title: source.labels.copyBranchName,
keywords: ["copy", "branch", "git", source.currentBranch],
icon: source.icons.copyBranchName,
run: source.copyBranchName,
visibility: "query",
}),
);
}
if (source.capabilities.canShowSetup) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:show-setup",
rank: 24,
title: source.labels.showSetup,
keywords: ["setup", "provision", "install", "bootstrap"],
icon: source.icons.showSetup,
action: { id: "workspace.setup.show", scope: "workspace" },
visibility: "query",
}),
);
}
// Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx:
// their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a
// global registration would list two entries that silently no-op on /settings, /sessions,
// /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why
// the three toggles render in two non-adjacent sections. Don't "tidy" them back together.
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-explorer-sidebar",
rank: 25,
title: source.labels.toggleExplorerSidebar,
keywords: ["toggle", "sidebar", "explorer", "files", "changes"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "sidebar" },
visibility: "query",
}),
);
// `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as
// `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry
// where that function is skipped — otherwise the palette lists "Toggle focus mode" twice.
if (!source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-focus-mode",
rank: 26,
title: source.labels.toggleFocusMode,
keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"],
icon: source.icons.toggleFocusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
visibility: "query",
}),
);
}
contributions.push(...buildLabelContributions(source));
return contributions;
}
/**
* One choice per catalog label, ticked when the current workspace carries it. Selecting toggles
* assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a
* multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing.
*/
function buildLabelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (!source.labelCatalog) return [];
return source.labelCatalog.map((choice, index) => ({
id: `workspace:label:${choice.name}`,
group: "workspace",
groupRank: -1,
rank: 30 + index,
keywords: ["label", "tag", choice.name],
visibility: "query" as const,
run: () => source.toggleLabel(choice.name, !choice.assigned),
presentation: {
kind: "choice" as const,
path: [source.labels.labelsGroup, choice.name] as const,
icon: choice.icon,
selected: choice.assigned,
testId: `command-center-workspace-label-${choice.name}`,
},
}));
}
@@ -1,4 +1,4 @@
import { useMemo, type ReactElement } from "react";
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownToLine,
@@ -8,12 +8,16 @@ import {
Copy,
Files,
Focus,
GitBranch,
GitCompareArrows,
GitPullRequest,
Globe,
ListChecks,
Move,
PanelRight,
Pencil,
Pin,
PinOff,
RotateCw,
Rows2,
SquarePen,
@@ -25,23 +29,35 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useHostFeature } from "@/runtime/host-features";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
buildWorkspaceLabelPickerRows,
useWorkspaceLabelProjection,
workspaceLabelErrorMessage,
workspaceLabels,
} from "@/workspace-labels";
import { getLabelCommandCenterIcon } from "@/workspace-labels/command-center-icon";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { getCommandCenterIcon } from "./icon";
import type { CommandCenterIcon } from "./contributions";
import { useCommandCenterActions } from "./provider";
import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterLabelChoice,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
@@ -65,6 +81,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
moveTab: getCommandCenterIcon(Move),
focusMode: getCommandCenterIcon(ArrowDownToLine),
explorerSidebar: getCommandCenterIcon(PanelRight),
// Workspace management action icons
copyPath: getCommandCenterIcon(Copy),
copyBranchName: getCommandCenterIcon(GitBranch),
pin: getCommandCenterIcon(Pin),
unpin: getCommandCenterIcon(PinOff),
showSetup: getCommandCenterIcon(ListChecks),
toggleFocusMode: getCommandCenterIcon(Focus),
};
const OPEN_PANEL_LABEL_KEYS = {
@@ -86,6 +109,58 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
/**
* The label catalog and its toggle callback, pulled out of `useWorkspaceCommandCenterActions` to
* keep that hook under the complexity limit. Null while the host's catalog hasn't loaded, so the
* group is omitted rather than shown empty — the same rule `WorkspaceLabelPickerPage` follows for
* the sidebar's own picker.
*/
function useWorkspaceLabelCatalog(
serverId: string | null,
fields: { id: string; labels: readonly string[] } | null,
): {
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
toggleLabel: (name: string, assigned: boolean) => void;
} {
const { labels: labelDefinitions, targetHost: labelHost } = useWorkspaceLabelProjection(
serverId ?? undefined,
);
const toast = useToast();
const assignedLabels = fields?.labels;
const labelCatalog = useMemo<readonly WorkspaceCommandCenterLabelChoice[] | null>(
() =>
labelHost?.status === "online" && assignedLabels
? buildWorkspaceLabelPickerRows({ labels: labelDefinitions, assigned: assignedLabels }).map(
(row) => ({
name: row.name,
assigned: row.assigned,
icon: getLabelCommandCenterIcon(row.color),
}),
)
: null,
[assignedLabels, labelDefinitions, labelHost?.status],
);
const toggleLabel = useCallback(
async (name: string, assigned: boolean) => {
if (!serverId || !fields) return;
const definition = labelDefinitions.find((label) => label.name === name);
if (!definition) return;
try {
await workspaceLabels.setAssignment({
serverId,
workspaceId: fields.id,
label: definition,
assigned,
});
} catch (cause) {
toast.error(workspaceLabelErrorMessage(cause));
}
},
[fields, labelDefinitions, serverId, toast],
);
return { labelCatalog, toggleLabel };
}
export function useWorkspaceCommandCenterActions(): void {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -104,8 +179,29 @@ export function useWorkspaceCommandCenterActions(): void {
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
// One narrow projection for the workspace management contribution fields. The registry's snapshot
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
id: workspace.id,
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const currentBranch = fields?.currentBranch ?? null;
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
const persistenceKey =
serverId && fields
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
: null;
const canShowSetup = useWorkspaceSetupStore((state) =>
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
@@ -113,6 +209,27 @@ export function useWorkspaceCommandCenterActions(): void {
icons: GIT_ACTION_ICONS,
});
const runGitAction = useGitActionRunner();
const clipboard = useWorkspaceClipboardActions();
const copyPath = useCallback(() => {
if (!fields) return;
clipboard.copyPath({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const copyBranchName = useCallback(() => {
if (!fields) return;
clipboard.copyBranchName({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const { labelCatalog, toggleLabel } = useWorkspaceLabelCatalog(serverId, fields);
const actions = useMemo(
() =>
@@ -152,6 +269,14 @@ export function useWorkspaceCommandCenterActions(): void {
closePane: t("settings.shortcuts.help.closePane"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
// Workspace management labels
rename: t("sidebar.workspace.actions.rename"),
copyPath: t("workspace.header.actions.copyPath"),
copyBranchName: t("workspace.header.actions.copyBranchName"),
pin: t("sidebar.workspace.actions.pin"),
unpin: t("sidebar.workspace.actions.unpin"),
showSetup: t("workspace.header.actions.showSetup"),
labelsGroup: t("workspaceLabels.title"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -162,27 +287,43 @@ export function useWorkspaceCommandCenterActions(): void {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
canPin,
canShowSetup,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
currentBranch,
isPinned,
labelCatalog,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
copyPath,
copyBranchName,
toggleLabel,
}),
[
activeTabIndex,
activeTabKind,
canPin,
canShowSetup,
copyBranchName,
copyPath,
currentBranch,
focusedTabs.length,
gitActions,
isCompact,
isGit,
isPinned,
keyboardActionDispatcher,
labelCatalog,
overrides,
runGitAction,
t,
toggleLabel,
],
);
@@ -31,7 +31,10 @@ export function resolveWorkspaceCommandCenterShortcuts({
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
toggleFocusMode: resolveShortcutKeysForAction("toggle-focus", overrides, platform) ?? undefined,
toggleExplorerSidebar:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
// Workspace management shortcuts
pinWorkspace: resolveShortcutKeysForAction("pin-workspace", overrides, platform) ?? undefined,
};
}
@@ -9,8 +9,6 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
memo,
useCallback,
@@ -36,7 +34,8 @@ import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -135,7 +134,6 @@ import {
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -1263,42 +1261,14 @@ function WorkspaceRowWithMenu({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
@@ -1308,13 +1278,6 @@ function WorkspaceRowWithMenu({
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -1373,14 +1336,10 @@ function WorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
/>
<AdaptiveRenameModal
<WorkspaceRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
workspace={workspace}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
@@ -40,14 +40,11 @@ import {
CircleX,
} from "lucide-react-native";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -626,42 +623,17 @@ function StatusWorkspaceRowWithMenu({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -719,14 +691,10 @@ function StatusWorkspaceRowWithMenu({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
<AdaptiveRenameModal
<WorkspaceRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
workspace={workspace}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
@@ -2,23 +2,20 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
@@ -108,42 +105,14 @@ export function SidebarWorkspaceRow({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
const handleCopyBranchName = useCallback(() => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
@@ -153,13 +122,6 @@ export function SidebarWorkspaceRow({
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
@@ -206,14 +168,10 @@ export function SidebarWorkspaceRow({
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
<WorkspaceRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
workspace={workspace}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
const WORKSPACE_RENAME_ACTIONS: readonly KeyboardActionId[] = ["workspace.rename"];
/**
* The rename dialog used to live only on the sidebar row, so it was unreachable whenever that row
* was not rendered — a collapsed project or status group, a collapsed Pinned section, or focus
* mode. This is the same fix `use-global-workspace-pin-action.ts` applies to pin: one registration
* keyed on the active route selection.
*
* Sidebar rows keep their own modal instances. They rename *their* workspace, not the active one.
*/
export function WorkspaceRenameHost() {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const routeWorkspaceId = selection?.workspaceId ?? null;
// Narrow projection so the dialog doesn't re-render on every gitRuntime/diffStat tick.
// `id` is projected rather than reusing the route id: the route carries an opaque workspace id
// that is not guaranteed to equal the descriptor id, and setWorkspaceTitle needs the descriptor.
const fields = useWorkspaceFields(serverId, routeWorkspaceId, (workspace) => ({
id: workspace.id,
name: workspace.name,
title: workspace.title ?? null,
}));
const [isOpen, setIsOpen] = useState(false);
const openFrameRef = useRef<number | null>(null);
const cancelPendingOpen = useCallback(() => {
if (openFrameRef.current !== null) {
cancelAnimationFrame(openFrameRef.current);
openFrameRef.current = null;
}
}, []);
useEffect(() => cancelPendingOpen, [cancelPendingOpen]);
// The command center closes and dispatches in the same React batch, so opening synchronously
// would mount this modal while the palette is still unmounting — on Android the portal stacking
// lands the dialog behind it, and on web the palette's teardown steals focus back from the
// input. Wait a frame. (The caller has already cleared the focus-restore element.)
const handle = useCallback(() => {
if (!fields) return false;
cancelPendingOpen();
openFrameRef.current = requestAnimationFrame(() => {
openFrameRef.current = null;
setIsOpen(true);
});
return true;
}, [cancelPendingOpen, fields]);
const handleClose = useCallback(() => {
cancelPendingOpen();
setIsOpen(false);
}, [cancelPendingOpen]);
// Closing on workspace change avoids renaming whatever the user navigated to instead.
useEffect(() => {
cancelPendingOpen();
setIsOpen(false);
}, [cancelPendingOpen, serverId, routeWorkspaceId]);
useKeyboardActionHandler({
handlerId: "workspace-rename-global",
actions: WORKSPACE_RENAME_ACTIONS,
enabled: serverId !== null && fields !== null,
priority: 0,
handle,
});
const workspace = useMemo(
() =>
serverId && fields
? { serverId, workspaceId: fields.id, name: fields.name, title: fields.title }
: null,
[fields, serverId],
);
if (!workspace) return null;
return (
<WorkspaceRenameModal
visible={isOpen}
workspace={workspace}
onClose={handleClose}
testID="workspace-rename-modal-global"
/>
);
}
@@ -0,0 +1,70 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface RenamableWorkspace {
serverId: string;
workspaceId: string;
name: string;
title?: string | null;
}
export interface WorkspaceRenameModalProps {
visible: boolean;
workspace: RenamableWorkspace;
onClose: () => void;
/**
* Prefix for the modal's testIDs. The sidebar callers must keep passing
* `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts
* builds its locators from exactly that prefix.
*/
testID?: string;
}
/**
* Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own
* open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open.
*/
export function WorkspaceRenameModal({
visible,
workspace,
onClose,
testID,
}: WorkspaceRenameModalProps) {
const { t } = useTranslation();
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const renameAsync = renameMutation.mutateAsync;
const handleSubmit = useCallback(
async (value: string) => {
await renameAsync(value.trim());
},
[renameAsync],
);
return (
<AdaptiveRenameModal
visible={visible}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={onClose}
onSubmit={handleSubmit}
testID={testID}
/>
);
}
@@ -0,0 +1,58 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import * as Clipboard from "expo-clipboard";
import { useToast } from "@/contexts/toast-context";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
// Everything the copy actions actually need. Kept narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface CopyableWorkspace {
workspaceId: string;
workspaceDirectory: string | null | undefined;
currentBranch: string | null | undefined;
}
export interface WorkspaceClipboardActions {
copyPath: (workspace: CopyableWorkspace) => void;
copyBranchName: (workspace: CopyableWorkspace) => void;
}
export function useWorkspaceClipboardActions(): WorkspaceClipboardActions {
const { t } = useTranslation();
const toast = useToast();
const copyPath = useCallback(
(workspace: CopyableWorkspace) => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
},
[t, toast],
);
const copyBranchName = useCallback(
(workspace: CopyableWorkspace) => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
},
[t, toast],
);
return useMemo(() => ({ copyPath, copyBranchName }), [copyBranchName, copyPath]);
}
@@ -51,7 +51,10 @@ export type KeyboardActionId =
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.pin";
| "workspace.pin"
// Command-center only: no keybind, so these are absent from route-shortcut.ts.
| "workspace.rename"
| "workspace.setup.show";
export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
@@ -106,7 +109,9 @@ export type KeyboardActionDefinition =
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope }
| { id: "workspace.pin"; scope: KeyboardActionScope };
| { id: "workspace.pin"; scope: KeyboardActionScope }
| { id: "workspace.rename"; scope: KeyboardActionScope }
| { id: "workspace.setup.show"; scope: KeyboardActionScope };
export interface KeyboardActionHandler {
handlerId: string;
@@ -3304,7 +3304,8 @@ function WorkspaceScreenContent({
],
);
const workspaceKeyboardActionsEnabled = Boolean(
// Shared by every handler below: these actions only exist on a focused workspace route.
const workspaceActionsEnabled = Boolean(
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
);
@@ -3323,7 +3324,7 @@ function WorkspaceScreenContent({
"workspace.browser.new",
"workspace.tab.menu.open",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceTabAction,
@@ -3341,7 +3342,7 @@ function WorkspaceScreenContent({
"workspace.tab.target.changes",
"workspace.tab.target.files",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceDirectTargetAction,
@@ -3354,7 +3355,7 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["workspace.tab.open"] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePanelOpenAction,
@@ -3373,7 +3374,7 @@ function WorkspaceScreenContent({
"workspace.tab.copy-id",
"workspace.tab.copy-file-path",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabMetadataAction,
@@ -3390,7 +3391,7 @@ function WorkspaceScreenContent({
"workspace.tab.close-right",
"workspace.tab.close-others",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabCloseAction,
@@ -3416,7 +3417,7 @@ function WorkspaceScreenContent({
"workspace.pane.close",
"workspace.focus.toggle",
] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePaneAction,
@@ -3429,12 +3430,28 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
enabled: workspaceKeyboardActionsEnabled,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceSidebarAction,
});
// Gated on the same predicate as the header menu item, so the command center never lists a
// Show setup entry the menu would hide.
// Gated by isActive so the handler is only dispatched when the workspace has visible setup;
// the command center contribution is separately gated by canShowSetup in workspace-registration.
useKeyboardActionHandler({
handlerId: `workspace-setup-show:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: ["workspace.setup.show"] as const,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => showWorkspaceSetup,
handle: () => {
handleOpenSetupTab();
return true;
},
});
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor);
const activeFilePath = activeFileFields.path;
@@ -0,0 +1,19 @@
import type { WorkspaceLabelColor } from "@getpaseo/protocol/workspace-labels";
import type { CommandCenterIcon } from "@/command-center/contributions";
import { WorkspaceLabelDot } from "./swatch";
// One stable component per color for the life of the module — `getCommandCenterIcon` in
// `@/command-center/icon` does the same thing keyed by lucide component identity. A label's dot
// has no lucide component to key off, so the cache is keyed by color name instead.
const cache = new Map<WorkspaceLabelColor, CommandCenterIcon>();
/** The color dot a label's row draws everywhere else, adapted to the command center's icon shape. */
export function getLabelCommandCenterIcon(color: WorkspaceLabelColor): CommandCenterIcon {
const cached = cache.get(color);
if (cached) return cached;
function LabelCommandCenterIcon() {
return <WorkspaceLabelDot color={color} />;
}
cache.set(color, LabelCommandCenterIcon);
return LabelCommandCenterIcon;
}