From fb9f7fb98a49409c2fcdf63456cbe5c7421847c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 1 Aug 2026 15:38:07 +0200 Subject: [PATCH] Make the command center stable and workspace-aware (#2749) * feat(app): expand command center actions Keep command center navigation stable by revealing only clipped rows without smooth centering. Prioritize a concise default list while making workspace, Git, pane, and creation actions searchable through the existing contribution and action-policy paths. * test(app): search for query-only command action Add project is intentionally absent from the unfiltered command center. Exercise the overlay flow through search before selecting it. --- .../app/e2e/command-center-scroll.spec.ts | 53 ++++ .../command-center-workspace-actions.spec.ts | 34 +++ .../app/e2e/helpers/command-center-scroll.ts | 167 ++++++++++++ .../command-center-workspace-actions.ts | 66 +++++ .../app/e2e/provider-settings-refresh.spec.ts | 1 + packages/app/src/app/_layout.tsx | 5 +- .../app/src/command-center/command-center.tsx | 238 +++++++---------- .../app/src/command-center/results.test.ts | 10 + .../src/command-center/root-registration.tsx | 241 ++++++++++++++++++ .../workspace-contributions.test.ts | 139 ++++++++++ .../command-center/workspace-contributions.ts | 175 +++++++++++++ .../command-center/workspace-registration.tsx | 111 ++++++++ packages/app/src/git/action-icons.tsx | 31 +++ packages/app/src/git/actions-split-button.tsx | 28 +- packages/app/src/git/diff-pane.tsx | 29 +-- packages/app/src/git/use-actions.tsx | 22 ++ packages/app/src/git/workspace-actions.tsx | 36 +-- packages/app/src/hooks/use-shortcut-keys.ts | 18 +- .../keyboard/keyboard-action-dispatcher.ts | 2 + .../app/src/keyboard/keyboard-shortcuts.ts | 14 + .../screens/workspace/workspace-screen.tsx | 5 + 21 files changed, 1178 insertions(+), 247 deletions(-) create mode 100644 packages/app/e2e/command-center-scroll.spec.ts create mode 100644 packages/app/e2e/command-center-workspace-actions.spec.ts create mode 100644 packages/app/e2e/helpers/command-center-scroll.ts create mode 100644 packages/app/e2e/helpers/command-center-workspace-actions.ts create mode 100644 packages/app/src/command-center/root-registration.tsx create mode 100644 packages/app/src/command-center/workspace-contributions.test.ts create mode 100644 packages/app/src/command-center/workspace-contributions.ts create mode 100644 packages/app/src/command-center/workspace-registration.tsx create mode 100644 packages/app/src/git/action-icons.tsx diff --git a/packages/app/e2e/command-center-scroll.spec.ts b/packages/app/e2e/command-center-scroll.spec.ts new file mode 100644 index 000000000..dbfd57b79 --- /dev/null +++ b/packages/app/e2e/command-center-scroll.spec.ts @@ -0,0 +1,53 @@ +import { test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + expectCommandCenterToRemainAtTheTop, + expectPrimaryCommandCenterActions, + expectTightTwoLineResultSpacing, + expectVisibleKeyboardNavigationNotToScroll, + expectWorkspaceResultsToBeLimitedUntilSearch, + observeCommandCenterScroll, + openCommandCenterWithKeyboard, +} from "./helpers/command-center-scroll"; +import { seedWorkspace } from "./helpers/seed-client"; + +test.use({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36", +}); + +test("opening the command center keeps its first result fully visible", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-scroll-", + title: "Command Center Scroll 00", + }); + + try { + for (let index = 1; index <= 8; index += 1) { + const created = await seeded.client.createWorkspace({ + source: { + kind: "worktree", + projectId: seeded.projectId, + baseBranch: "main", + worktreeSlug: `command-center-scroll-${index}`, + }, + title: `Command Center Scroll ${String(index).padStart(2, "0")}`, + }); + if (!created.workspace) { + throw new Error(created.error ?? `Failed to seed workspace ${index}`); + } + } + + await gotoAppShell(page); + await observeCommandCenterScroll(page); + const panel = await openCommandCenterWithKeyboard(page); + + await expectCommandCenterToRemainAtTheTop(page); + await expectPrimaryCommandCenterActions(panel); + await expectVisibleKeyboardNavigationNotToScroll(page, panel); + await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00"); + await expectWorkspaceResultsToBeLimitedUntilSearch(panel); + } finally { + await seeded.cleanup(); + } +}); diff --git a/packages/app/e2e/command-center-workspace-actions.spec.ts b/packages/app/e2e/command-center-workspace-actions.spec.ts new file mode 100644 index 000000000..17d6cb03c --- /dev/null +++ b/packages/app/e2e/command-center-workspace-actions.spec.ts @@ -0,0 +1,34 @@ +import { test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { openCommandCenter } from "./helpers/command-center"; +import { + createTerminalFromCommandCenter, + expectDefaultWorkspaceActions, + expectWorkspaceActionsAvailableThroughSearch, + makeWorkspacePrimaryGitActionCommit, + openWorkspaceFromCommandCenter, +} from "./helpers/command-center-workspace-actions"; +import { seedWorkspace } from "./helpers/seed-client"; + +test("workspace actions use the Git policy and dispatch through the active workspace", async ({ + page, +}) => { + const title = "Command Center Workspace Actions"; + const seeded = await seedWorkspace({ + repoPrefix: "command-center-workspace-actions-", + title, + }); + + try { + await makeWorkspacePrimaryGitActionCommit(seeded); + await gotoAppShell(page); + await openWorkspaceFromCommandCenter(page, seeded, title); + + const panel = await openCommandCenter(page); + await expectDefaultWorkspaceActions(panel); + await expectWorkspaceActionsAvailableThroughSearch(panel); + await createTerminalFromCommandCenter(page, panel); + } finally { + await seeded.cleanup(); + } +}); diff --git a/packages/app/e2e/helpers/command-center-scroll.ts b/packages/app/e2e/helpers/command-center-scroll.ts new file mode 100644 index 000000000..860444129 --- /dev/null +++ b/packages/app/e2e/helpers/command-center-scroll.ts @@ -0,0 +1,167 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +interface CommandCenterScrollSample { + scrollTop: number; + firstResultTop: number; + viewportTop: number; +} + +interface CommandCenterScrollObservation { + done: boolean; + samples: CommandCenterScrollSample[]; +} + +type ScrollOracleWindow = Window & { + __commandCenterScrollObservation?: CommandCenterScrollObservation; +}; + +export async function observeCommandCenterScroll(page: Page): Promise { + await page.evaluate(() => { + const oracleWindow = window as ScrollOracleWindow; + oracleWindow.__commandCenterScrollObservation = { done: false, samples: [] }; + + const discoverPanel = new MutationObserver(() => { + const panel = document.querySelector('[data-testid="command-center-panel"]'); + const firstResult = panel?.querySelector('[role="button"]'); + if (!panel || !firstResult) return; + + let viewport = firstResult.parentElement; + while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) { + viewport = viewport.parentElement; + } + if (!viewport || viewport === panel) return; + + discoverPanel.disconnect(); + let frame = 0; + const sample = () => { + const observation = oracleWindow.__commandCenterScrollObservation; + if (!observation) return; + const firstResultBounds = firstResult.getBoundingClientRect(); + const viewportBounds = viewport.getBoundingClientRect(); + observation.samples.push({ + scrollTop: viewport.scrollTop, + firstResultTop: firstResultBounds.top, + viewportTop: viewportBounds.top, + }); + frame += 1; + if (frame < 60) { + requestAnimationFrame(sample); + return; + } + observation.done = true; + }; + requestAnimationFrame(sample); + }); + + discoverPanel.observe(document.body, { childList: true, subtree: true }); + }); +} + +export async function openCommandCenterWithKeyboard(page: Page): Promise { + await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 }); + await page.keyboard.press("Meta+K"); + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + return panel; +} + +export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise { + await page.waitForFunction( + () => (window as ScrollOracleWindow).__commandCenterScrollObservation?.done === true, + ); + const samples = await page.evaluate( + () => (window as ScrollOracleWindow).__commandCenterScrollObservation?.samples ?? [], + ); + + expect(samples.length).toBeGreaterThan(0); + expect( + Math.min(...samples.map((sample) => sample.firstResultTop - sample.viewportTop)), + ).toBeGreaterThanOrEqual(0); + expect(Math.max(...samples.map((sample) => sample.scrollTop))).toBe(0); +} + +export async function expectPrimaryCommandCenterActions(panel: Locator): Promise { + const expectedActions = [ + { title: "New workspace", shortcut: "⌘N" }, + { title: "History" }, + { title: "Schedules" }, + { title: "Settings", shortcut: "⌘," }, + { title: "Keyboard shortcuts", shortcut: "?" }, + ] as const; + + for (const action of expectedActions) { + const row = panel.getByRole("button").filter({ hasText: action.title }); + await expect(row).toBeVisible(); + if ("shortcut" in action) await expect(row).toContainText(action.shortcut); + } + + await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0); + await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0); + + const input = panel.getByTestId("command-center-input"); + await input.fill("add project"); + await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toBeVisible(); + await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0); + await input.fill("home"); + await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible(); + await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0); + await input.fill(""); +} + +export async function expectVisibleKeyboardNavigationNotToScroll( + page: Page, + panel: Locator, +): Promise { + for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown"); + + const scrollSamples = await panel + .getByRole("button") + .first() + .evaluate(async (firstResult) => { + let viewport = firstResult.parentElement; + while (viewport && viewport.scrollHeight <= viewport.clientHeight) { + viewport = viewport.parentElement; + } + if (!viewport) throw new Error("Could not find the command center scroll viewport"); + + const samples: number[] = []; + for (let frame = 0; frame < 20; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + samples.push(viewport.scrollTop); + } + return samples; + }); + + expect(Math.max(...scrollSamples)).toBe(0); +} + +export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise { + const workspaces = panel.locator( + '[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])', + ); + const distinctWorkspaceCount = async () => + new Set( + await workspaces.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-testid")), + ), + ).size; + + await expect.poll(distinctWorkspaceCount).toBe(5); + + await panel.getByTestId("command-center-input").fill("Command Center Scroll"); + await expect.poll(distinctWorkspaceCount).toBe(9); +} + +export async function expectTightTwoLineResultSpacing( + panel: Locator, + title: string, +): Promise { + const row = panel.getByText(title, { exact: true }).locator(".."); + const titleTop = await row + .getByText(title, { exact: true }) + .evaluate((element) => element.getBoundingClientRect().top); + const subtitleTop = await row + .getByTestId("command-center-workspace-subtitle") + .evaluate((element) => element.getBoundingClientRect().top); + expect(subtitleTop - titleTop).toBeLessThanOrEqual(18); +} diff --git a/packages/app/e2e/helpers/command-center-workspace-actions.ts b/packages/app/e2e/helpers/command-center-workspace-actions.ts new file mode 100644 index 000000000..c6d7bd4d2 --- /dev/null +++ b/packages/app/e2e/helpers/command-center-workspace-actions.ts @@ -0,0 +1,66 @@ +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { openCommandCenter } from "./command-center"; +import { expectAppRoute } from "./route-assertions"; +import type { SeededWorkspace } from "./seed-client"; +import { getServerId } from "./server-id"; +import { expectTerminalTabOpen } from "./workspace-tabs"; + +function action(panel: Locator, title: string): Locator { + const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return panel.getByRole("button", { name: new RegExp(`^${escapedTitle}(?:\\s|$)`) }); +} + +export async function makeWorkspacePrimaryGitActionCommit(seeded: SeededWorkspace): Promise { + writeFileSync(path.join(seeded.repoPath, "command-center-dirty.txt"), "dirty\n"); + const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath); + if (!refreshed.success) { + throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`); + } +} + +export async function openWorkspaceFromCommandCenter( + page: Page, + seeded: SeededWorkspace, + title: string, +): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(title); + await page.keyboard.press("Enter"); + await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), { + timeout: 30_000, + }); +} + +export async function expectDefaultWorkspaceActions(panel: Locator): Promise { + await expect(panel.getByText("Workspace actions", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(action(panel, "New agent")).toBeVisible(); + await expect(action(panel, "Commit")).toBeVisible(); + await expect(action(panel, "Push")).toHaveCount(0); + await expect(action(panel, "New terminal")).toHaveCount(0); + await expect(action(panel, "Split pane right")).toHaveCount(0); + await expect(action(panel, "Home")).toHaveCount(0); + await expect(action(panel, "Add project")).toHaveCount(0); +} + +export async function expectWorkspaceActionsAvailableThroughSearch(panel: Locator): Promise { + const input = panel.getByTestId("command-center-input"); + await input.fill("push"); + await expect(action(panel, "Push")).toBeVisible(); + + await input.fill("home"); + await expect(action(panel, "Home")).toBeVisible(); + + await input.fill("git"); + await expect.poll(() => panel.getByRole("button").count()).toBeGreaterThan(1); +} + +export async function createTerminalFromCommandCenter(page: Page, panel: Locator): Promise { + await panel.getByTestId("command-center-input").fill("terminal"); + await page.keyboard.press("Enter"); + await expectTerminalTabOpen(page); +} diff --git a/packages/app/e2e/provider-settings-refresh.spec.ts b/packages/app/e2e/provider-settings-refresh.spec.ts index 3ade95634..03161f242 100644 --- a/packages/app/e2e/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/provider-settings-refresh.spec.ts @@ -156,6 +156,7 @@ test.describe("provider settings overlay stack", () => { await page.keyboard.press("ControlOrMeta+K"); await expect(commandCenter).toBeVisible({ timeout: 10_000 }); + await commandCenter.getByTestId("command-center-input").fill("add project"); await commandCenter.getByText("Add project", { exact: true }).click(); const addProject = page.getByTestId("add-project-flow"); await expect(addProject).toBeVisible({ timeout: 10_000 }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5429c82e8..babbdce14 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -21,8 +21,10 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center"; +import { CommandCenter } from "@/command-center/command-center"; +import { CommandCenterRootActions } from "@/command-center/root-registration"; import { CommandCenterProvider } from "@/command-center/provider"; +import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration"; import { AddProjectFlowHost } from "@/components/add-project-flow-host"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { DownloadToast } from "@/components/download-toast"; @@ -555,6 +557,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 51b94eeca..e67b6a6de 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -5,13 +5,15 @@ import { Text, TextInput, View, + type LayoutChangeEvent, type ListRenderItemInfo, + type NativeScrollEvent, + type NativeSyntheticEvent, type PressableStateCallbackType, } from "react-native"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { router, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; -import { Check, ChevronRight, Folder, Home, Plus, Settings } from "lucide-react-native"; +import { Check, ChevronRight, Folder } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetBackdrop, @@ -25,11 +27,9 @@ import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; -import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; -import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useProjects } from "@/hooks/use-projects"; import { OverlayLayerProvider, @@ -39,22 +39,16 @@ import { import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; -import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; import { focusWithRetries } from "@/utils/web-focus"; -import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; -import { getShortcutOs } from "@/utils/shortcut-platform"; -import type { ShortcutKey } from "@/utils/format-shortcut"; -import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions"; -import { useCommandCenterActions, useCommandCenterContributions } from "./provider"; +import { useCommandCenterContributions } from "./provider"; import { buildContributionSections, joinSubtitleParts, @@ -79,113 +73,9 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({ color: theme.colors.foregroundMuted, })); -const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted })); -const ThemedSettings = withUnistyles(Settings, (theme) => ({ - color: theme.colors.foregroundMuted, -})); -const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted })); const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; - -function PlusIcon({ size }: CommandCenterIconProps) { - return ; -} - -function SettingsIcon({ size }: CommandCenterIconProps) { - return ; -} - -function HomeIcon({ size }: CommandCenterIconProps) { - return ; -} - -function resolveActionShortcutKeys( - actionId: string | undefined, - overrides: Record, -): ShortcutKey[][] | undefined { - if (!actionId) return undefined; - const platform = { - isMac: getShortcutOs() === "mac", - isDesktop: getIsElectronRuntime(), - }; - const bindingId = getBindingIdForAction(actionId, platform); - if (!bindingId) return undefined; - const override = overrides[bindingId]; - if (override) return chordStringToShortcutKeys(override); - const defaultKeys = getDefaultKeysForAction(actionId, platform); - return defaultKeys ? [defaultKeys] : undefined; -} - -export function CommandCenterRootActions() { - const { t } = useTranslation(); - const { overrides } = useKeyboardShortcutOverrides(); - const openAddProject = useOpenAddProject(); - const settingsRoute = useMemo(() => buildSettingsRoute(), []); - const homeRoute = useMemo(() => buildOpenProjectRoute(), []); - const actions = useMemo( - () => [ - { - id: "new-agent", - group: "actions", - groupRank: 0, - rank: 0, - keywords: ["open", "project", "folder", "workspace", "repo"], - visibility: "always", - run: () => { - clearCommandCenterFocusRestoreElement(); - openAddProject(); - }, - presentation: { - kind: "action", - title: t("shell.commandCenter.addProject"), - sectionTitle: t("shell.commandCenter.actions"), - icon: PlusIcon, - shortcutKeys: resolveActionShortcutKeys("new-agent", overrides), - }, - }, - { - id: "home", - group: "actions", - groupRank: 0, - rank: 1, - keywords: ["home", "start", "import", "session", "pair", "device", "providers"], - visibility: "always", - run: () => { - clearCommandCenterFocusRestoreElement(); - router.push(homeRoute); - }, - presentation: { - kind: "action", - title: t("shell.commandCenter.home"), - sectionTitle: t("shell.commandCenter.actions"), - icon: HomeIcon, - }, - }, - { - id: "settings", - group: "actions", - groupRank: 0, - rank: 2, - keywords: ["settings", "preferences", "config", "configuration"], - visibility: "always", - run: () => { - clearCommandCenterFocusRestoreElement(); - router.push(settingsRoute); - }, - presentation: { - kind: "action", - title: t("sidebar.actions.settings"), - sectionTitle: t("shell.commandCenter.actions"), - icon: SettingsIcon, - }, - }, - ], - [homeRoute, openAddProject, overrides, settingsRoute, t], - ); - - useCommandCenterActions({ sourceId: "root", enabled: true, actions }); - return null; -} +const DEFAULT_CATEGORY_RESULT_LIMIT = 5; function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; @@ -205,6 +95,10 @@ function matchesQuery(searchText: string, query: string): boolean { return !normalized || searchText.includes(normalized); } +function limitDefaultCategoryResults(results: Result[], query: string): Result[] { + return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); +} + function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] { const { t } = useTranslation(); const { agents } = useAggregatedAgents(); @@ -249,36 +143,40 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe const workspaceTitleByKey = new Map( allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]), ); - const workspaces = allWorkspaces.filter((workspace) => - matchesQuery(workspace.searchText, query), + const workspaces = limitDefaultCategoryResults( + allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)), + query, + ); + const agentResults = limitDefaultCategoryResults( + agents + .map((agent) => { + const title = agent.title || t("shell.commandCenter.newAgent"); + const workspaceTitle = agent.workspaceId + ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) + : undefined; + const location = workspaceTitle ?? shortenPath(agent.cwd); + const subtitle = joinSubtitleParts([ + showHost ? agent.serverLabel : null, + location, + formatTimeAgo(agent.lastActivityAt), + ]); + return { + kind: "agent", + id: `agent:${agent.serverId}:${agent.id}`, + agent, + title, + subtitle, + searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(), + run: () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); + }, + }; + }) + .filter((agent) => matchesQuery(agent.searchText, query)) + .sort((left, right) => sortAgents(left.agent, right.agent)), + query, ); - const agentResults = agents - .map((agent) => { - const title = agent.title || t("shell.commandCenter.newAgent"); - const workspaceTitle = agent.workspaceId - ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) - : undefined; - const location = workspaceTitle ?? shortenPath(agent.cwd); - const subtitle = joinSubtitleParts([ - showHost ? agent.serverLabel : null, - location, - formatTimeAgo(agent.lastActivityAt), - ]); - return { - kind: "agent", - id: `agent:${agent.serverId}:${agent.id}`, - agent, - title, - subtitle, - searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(), - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); - }, - }; - }) - .filter((agent) => matchesQuery(agent.searchText, query)) - .sort((left, right) => sortAgents(left.agent, right.agent)); return [ { id: "workspaces", @@ -570,19 +468,54 @@ export function CommandCenter() { const listRef = useRef>(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef>(null); + const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 }); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible: state.open, isEnabled: showBottomSheet, onClose: state.close, }); - useEffect(() => { + const revealActiveResult = useCallback(() => { if (!state.open || !state.activeId) return; const index = state.rowIndexByResultId.get(state.activeId); if (index === undefined) return; + const { offset, visibleLength } = scrollMetricsRef.current; + if (visibleLength <= 0) return; + const rowTop = state.offsets[index]; + const rowBottom = rowTop + state.rows[index].height; + let nextOffset: number | null = null; + if (rowTop < offset) nextOffset = rowTop; + if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength; + if (nextOffset === null) return; const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current; - ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 }); - }, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]); + const boundedOffset = Math.max(0, nextOffset); + scrollMetricsRef.current.offset = boundedOffset; + ref?.scrollToOffset({ offset: boundedOffset, animated: false }); + }, [ + showBottomSheet, + state.activeId, + state.offsets, + state.open, + state.rowIndexByResultId, + state.rows, + ]); + useEffect(() => { + if (!state.open) { + scrollMetricsRef.current = { offset: 0, visibleLength: 0 }; + return; + } + revealActiveResult(); + }, [revealActiveResult, state.open]); + const handleListLayout = useCallback( + (event: LayoutChangeEvent) => { + scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height; + revealActiveResult(); + }, + [revealActiveResult], + ); + const handleListScroll = useCallback((event: NativeSyntheticEvent) => { + scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y; + }, []); useEffect(() => { if (!showBottomSheet || !state.open) return; const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); @@ -626,6 +559,9 @@ export function CommandCenter() { initialNumToRender: 12, maxToRenderPerBatch: 10, windowSize: 5, + onLayout: handleListLayout, + onScroll: handleListScroll, + scrollEventThrottle: 16, }; const keyPress = useCallback( ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key), @@ -786,10 +722,10 @@ const styles = StyleSheet.create((theme) => ({ title: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - lineHeight: 20, + lineHeight: 18, flexShrink: 1, }, - subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18 }, + subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 }, iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" }, rowShortcut: { flexShrink: 0 }, breadcrumb: { diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index 0bc8b7626..f54abe744 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -72,6 +72,16 @@ describe("Command Center result projection", () => { expect(projection.offsets).toEqual([0, 32, 68, 117]); }); + it("shows query-only actions only when their group matches a search", () => { + const contributions = [ + contribution({ id: "settings", group: "actions", groupRank: 0 }), + contribution({ id: "home", group: "actions", groupRank: 0, visibility: "query" }), + ]; + + expect(sectionResultIds(buildContributionSections(contributions, ""))).toEqual(["settings"]); + expect(sectionResultIds(buildContributionSections(contributions, "home"))).toEqual(["home"]); + }); + it("preserves active selection by id and falls back to the first result", () => { const first = workspace("workspace:first"); const second = workspace("workspace:second"); diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx new file mode 100644 index 000000000..09ed97dd2 --- /dev/null +++ b/packages/app/src/command-center/root-registration.tsx @@ -0,0 +1,241 @@ +import { useMemo } from "react"; +import { router, type Href } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { + CalendarClock, + FolderPlus, + History, + Home, + Keyboard, + Plus, + Settings, +} from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { getIsElectronRuntime } from "@/constants/layout"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { + buildOpenProjectRoute, + buildSchedulesRoute, + buildSessionsRoute, + buildSettingsRoute, +} from "@/utils/host-routes"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions"; +import { useCommandCenterActions } from "./provider"; + +const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedFolderPlus = withUnistyles(FolderPlus, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedHistory = withUnistyles(History, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedCalendarClock = withUnistyles(CalendarClock, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedKeyboard = withUnistyles(Keyboard, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedSettings = withUnistyles(Settings, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted })); + +function PlusIcon({ size }: CommandCenterIconProps) { + return ; +} + +function AddProjectIcon({ size }: CommandCenterIconProps) { + return ; +} + +function SettingsIcon({ size }: CommandCenterIconProps) { + return ; +} + +function HistoryIcon({ size }: CommandCenterIconProps) { + return ; +} + +function SchedulesIcon({ size }: CommandCenterIconProps) { + return ; +} + +function KeyboardIcon({ size }: CommandCenterIconProps) { + return ; +} + +function HomeIcon({ size }: CommandCenterIconProps) { + return ; +} + +export function CommandCenterRootActions() { + const { t } = useTranslation(); + const { overrides } = useKeyboardShortcutOverrides(); + const openAddProject = useOpenAddProject(); + const settingsRoute = useMemo(() => buildSettingsRoute(), []); + const homeRoute = useMemo(() => buildOpenProjectRoute(), []); + const sessionsRoute = useMemo(() => buildSessionsRoute(), []); + const schedulesRoute = useMemo(() => buildSchedulesRoute(), []); + const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); + const shortcutPlatform = useMemo( + () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), + [], + ); + const actions = useMemo( + () => [ + { + id: "add-project", + group: "actions", + groupRank: 0, + rank: 0, + keywords: ["open", "project", "folder", "workspace", "repo"], + visibility: "query", + run: () => { + clearCommandCenterFocusRestoreElement(); + openAddProject(); + }, + presentation: { + kind: "action", + title: t("shell.commandCenter.addProject"), + sectionTitle: t("shell.commandCenter.actions"), + icon: AddProjectIcon, + // The help id remains "new-agent" because user overrides are keyed by its binding id. + shortcutKeys: + resolveShortcutKeysForAction("new-agent", overrides, shortcutPlatform) ?? undefined, + }, + }, + { + id: "new-workspace", + group: "actions", + groupRank: 0, + rank: 1, + keywords: ["new", "workspace", "worktree", "branch"], + visibility: "always", + run: () => { + keyboardActionDispatcher.dispatch({ id: "workspace.new", scope: "sidebar" }); + }, + presentation: { + kind: "action", + title: t("sidebar.actions.newWorkspace"), + sectionTitle: t("shell.commandCenter.actions"), + icon: PlusIcon, + shortcutKeys: + resolveShortcutKeysForAction("new-workspace", overrides, shortcutPlatform) ?? undefined, + }, + }, + { + id: "home", + group: "actions", + groupRank: 0, + rank: 2, + keywords: ["home", "start", "import", "session", "pair", "device", "providers"], + visibility: "query", + run: () => { + clearCommandCenterFocusRestoreElement(); + router.push(homeRoute); + }, + presentation: { + kind: "action", + title: t("shell.commandCenter.home"), + sectionTitle: t("shell.commandCenter.actions"), + icon: HomeIcon, + }, + }, + { + id: "history", + group: "actions", + groupRank: 0, + rank: 3, + keywords: ["history", "sessions", "recent"], + visibility: "always", + run: () => { + clearCommandCenterFocusRestoreElement(); + router.push(sessionsRoute); + }, + presentation: { + kind: "action", + title: t("sidebar.sections.sessions"), + sectionTitle: t("shell.commandCenter.actions"), + icon: HistoryIcon, + }, + }, + { + id: "schedules", + group: "actions", + groupRank: 0, + rank: 4, + keywords: ["schedules", "scheduled", "automation", "recurring"], + visibility: "always", + run: () => { + clearCommandCenterFocusRestoreElement(); + router.push(schedulesRoute); + }, + presentation: { + kind: "action", + title: t("sidebar.sections.schedules"), + sectionTitle: t("shell.commandCenter.actions"), + icon: SchedulesIcon, + }, + }, + { + id: "settings", + group: "actions", + groupRank: 0, + rank: 5, + keywords: ["settings", "preferences", "config", "configuration"], + visibility: "always", + run: () => { + clearCommandCenterFocusRestoreElement(); + router.push(settingsRoute); + }, + presentation: { + kind: "action", + title: t("sidebar.actions.settings"), + sectionTitle: t("shell.commandCenter.actions"), + icon: SettingsIcon, + shortcutKeys: + resolveShortcutKeysForAction("toggle-settings", overrides, shortcutPlatform) ?? + undefined, + }, + }, + { + id: "keyboard-shortcuts", + group: "actions", + groupRank: 0, + rank: 6, + keywords: ["keyboard", "shortcuts", "keys", "hotkeys"], + visibility: "always", + run: () => setShortcutsDialogOpen(true), + presentation: { + kind: "action", + title: t("sidebar.help.shortcuts"), + sectionTitle: t("shell.commandCenter.actions"), + icon: KeyboardIcon, + shortcutKeys: + resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ?? + undefined, + }, + }, + ], + [ + homeRoute, + openAddProject, + overrides, + schedulesRoute, + sessionsRoute, + setShortcutsDialogOpen, + settingsRoute, + shortcutPlatform, + t, + ], + ); + + useCommandCenterActions({ sourceId: "root", enabled: true, actions }); + return null; +} diff --git a/packages/app/src/command-center/workspace-contributions.test.ts b/packages/app/src/command-center/workspace-contributions.test.ts new file mode 100644 index 000000000..346429f71 --- /dev/null +++ b/packages/app/src/command-center/workspace-contributions.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { GitAction, GitActions } from "@/git/policy"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import { + buildWorkspaceCommandCenterContributions, + type WorkspaceCommandCenterSource, +} from "./workspace-contributions"; + +function gitAction(id: GitAction["id"], label: string): GitAction { + return { + id, + label, + pendingLabel: `${label} pending`, + successLabel: `${label} complete`, + disabled: false, + status: "idle", + startsGroup: false, + handler: () => undefined, + }; +} + +function source(gitActions: GitActions): { + value: WorkspaceCommandCenterSource; + runGitActions: GitAction[]; + dispatched: KeyboardActionDefinition[]; +} { + const runGitActions: GitAction[] = []; + const dispatched: KeyboardActionDefinition[] = []; + return { + value: { + gitActions, + labels: { + section: "Workspace actions", + newAgent: "New agent", + newTerminal: "New terminal", + newBrowser: "New browser", + splitRight: "Split pane right", + splitDown: "Split pane down", + }, + icons: {}, + shortcuts: {}, + capabilities: { canSplitPanes: true, canOpenBrowserTabs: true }, + dispatch: (action) => dispatched.push(action), + runGitAction: (action) => runGitActions.push(action), + }, + runGitActions, + dispatched, + }; +} + +describe("workspace command center contributions", () => { + it("makes only the policy-selected primary Git action default-visible and runs it", () => { + const primary = gitAction("commit", "Commit"); + const fixture = source({ + primary, + secondary: [gitAction("push", "Push")], + menu: [], + }); + + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + const gitContributions = contributions.filter((item) => item.id.startsWith("git:")); + const defaultGitContributions = gitContributions.filter((item) => item.visibility === "always"); + + expect(defaultGitContributions.map((item) => item.id)).toEqual(["git:commit"]); + defaultGitContributions[0].run(); + expect(fixture.runGitActions).toEqual([primary]); + }); + + it("does not duplicate a primary action retained in the secondary policy list", () => { + const primary = gitAction("pull", "Pull"); + const fixture = source({ + primary, + secondary: [primary, gitAction("push", "Push")], + menu: [], + }); + + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + expect(contributions.filter((item) => item.id === "git:pull")).toHaveLength(1); + }); + + it("orders New agent before Git and keeps terminal, browser, and splits search-only", () => { + const fixture = source({ + primary: gitAction("commit", "Commit"), + secondary: [], + menu: [], + }); + + 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" }, + ]); + }); + + 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 }; + + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]); + }); + + it("dispatches every tab and pane command to the workspace scope", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + const contributions = buildWorkspaceCommandCenterContributions(fixture.value); + + for (const contribution of contributions) contribution.run(); + + expect(fixture.dispatched).toEqual([ + { id: "workspace.tab.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" }, + ]); + }); + + it("keeps workspace creation commands available outside Git", () => { + const fixture = source({ primary: null, secondary: [], menu: [] }); + + 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.startsWith("git:"))).toBe(false); + }); +}); diff --git a/packages/app/src/command-center/workspace-contributions.ts b/packages/app/src/command-center/workspace-contributions.ts new file mode 100644 index 000000000..7fd96c55e --- /dev/null +++ b/packages/app/src/command-center/workspace-contributions.ts @@ -0,0 +1,175 @@ +import type { GitAction, GitActions } from "@/git/policy"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; + +export interface WorkspaceCommandCenterLabels { + section: string; + newAgent: string; + newTerminal: string; + newBrowser: string; + splitRight: string; + splitDown: string; +} + +export interface WorkspaceCommandCenterIcons { + newAgent?: CommandCenterIcon; + newTerminal?: CommandCenterIcon; + newBrowser?: CommandCenterIcon; + splitRight?: CommandCenterIcon; + splitDown?: CommandCenterIcon; + git?(action: GitAction): CommandCenterIcon | undefined; +} + +export interface WorkspaceCommandCenterShortcuts { + newAgent?: ShortcutKey[][]; + newTerminal?: ShortcutKey[][]; + splitRight?: ShortcutKey[][]; + splitDown?: ShortcutKey[][]; + archiveWorkspace?: ShortcutKey[][]; +} + +export interface WorkspaceCommandCenterSource { + gitActions: GitActions; + labels: WorkspaceCommandCenterLabels; + icons: WorkspaceCommandCenterIcons; + shortcuts: WorkspaceCommandCenterShortcuts; + capabilities: { + canSplitPanes: boolean; + canOpenBrowserTabs: boolean; + }; + dispatch(action: KeyboardActionDefinition): void; + runGitAction(action: GitAction): void; +} + +function buildGitContribution( + source: WorkspaceCommandCenterSource, + action: GitAction, + rank: number, + visibility: "always" | "query", +): CommandCenterContribution { + return { + id: `git:${action.id}`, + group: "workspace", + groupRank: -1, + rank, + keywords: [action.id, "git"], + visibility, + run: () => source.runGitAction(action), + presentation: { + kind: "action", + title: action.label, + sectionTitle: source.labels.section, + icon: source.icons.git?.(action), + shortcutKeys: + action.id === "archive-workspace" ? source.shortcuts.archiveWorkspace : undefined, + }, + }; +} + +function buildWorkspaceAction(input: { + source: WorkspaceCommandCenterSource; + id: string; + rank: number; + title: string; + keywords: readonly string[]; + icon?: CommandCenterIcon; + shortcutKeys?: ShortcutKey[][]; + action: KeyboardActionDefinition; + visibility: "always" | "query"; +}): CommandCenterContribution { + return { + id: input.id, + group: "workspace", + groupRank: -1, + rank: input.rank, + keywords: input.keywords, + visibility: input.visibility, + run: () => input.source.dispatch(input.action), + presentation: { + kind: "action", + title: input.title, + sectionTitle: input.source.labels.section, + icon: input.icon, + shortcutKeys: input.shortcutKeys, + }, + }; +} + +export function buildWorkspaceCommandCenterContributions( + source: WorkspaceCommandCenterSource, +): CommandCenterContribution[] { + const contributions: CommandCenterContribution[] = [ + buildWorkspaceAction({ + source, + id: "tab:new-agent", + rank: 0, + title: source.labels.newAgent, + keywords: ["tab", "new", "agent", "chat"], + icon: source.icons.newAgent, + shortcutKeys: source.shortcuts.newAgent, + action: { id: "workspace.tab.new", scope: "workspace" }, + visibility: "always", + }), + ]; + const primary = source.gitActions.primary; + if (primary) contributions.push(buildGitContribution(source, primary, 1, "always")); + contributions.push( + buildWorkspaceAction({ + source, + id: "tab:new-terminal", + rank: 2, + title: source.labels.newTerminal, + keywords: ["terminal", "shell", "console"], + 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, + 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", + }), + ); + } + for (const [index, action] of source.gitActions.secondary.entries()) { + if (action.id === primary?.id) continue; + contributions.push(buildGitContribution(source, action, 10 + index, "query")); + } + return contributions; +} diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx new file mode 100644 index 000000000..b1ebfc4fe --- /dev/null +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -0,0 +1,111 @@ +import { useMemo, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native"; +import { getIsElectron } from "@/constants/platform"; +import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; +import { GIT_ACTION_ICONS } from "@/git/action-icons"; +import { useGitActionRunner, useGitActions } from "@/git/use-actions"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; +import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { getCommandCenterIcon } from "./icon"; +import type { CommandCenterIcon } from "./contributions"; +import { useCommandCenterActions } from "./provider"; +import { + buildWorkspaceCommandCenterContributions, + type WorkspaceCommandCenterShortcuts, +} from "./workspace-contributions"; + +const WORKSPACE_COMMAND_CENTER_ICONS = { + newAgent: getCommandCenterIcon(SquarePen), + newTerminal: getCommandCenterIcon(SquareTerminal), + newBrowser: getCommandCenterIcon(Globe), + splitRight: getCommandCenterIcon(Columns2), + splitDown: getCommandCenterIcon(Rows2), +}; + +function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined { + if (!element) return undefined; + function StaticIcon() { + return element; + } + return StaticIcon; +} + +function resolveWorkspaceShortcuts( + overrides: Readonly>, +): WorkspaceCommandCenterShortcuts { + const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() }; + return { + newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined, + newTerminal: + resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined, + splitRight: + resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined, + splitDown: + resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined, + archiveWorkspace: + resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined, + }; +} + +export function useWorkspaceCommandCenterActions(): void { + const { t } = useTranslation(); + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const isCompact = useIsCompactFormFactor(); + const { overrides } = useKeyboardShortcutOverrides(); + const { gitActions } = useGitActions({ + serverId: serverId ?? "", + cwd: cwd ?? "", + icons: GIT_ACTION_ICONS, + }); + const runGitAction = useGitActionRunner(); + + const actions = useMemo( + () => + buildWorkspaceCommandCenterContributions({ + gitActions, + labels: { + section: t("workspace.header.actions.workspaceActions"), + newAgent: t("workspace.tabs.actions.newAgent"), + newTerminal: t("workspace.tabs.actions.newTerminal"), + newBrowser: t("workspace.tabs.actions.newBrowser"), + splitRight: t("workspace.tabs.actions.splitRight"), + splitDown: t("workspace.tabs.actions.splitDown"), + }, + icons: { + ...WORKSPACE_COMMAND_CENTER_ICONS, + git: (action) => staticIcon(action.icon), + }, + shortcuts: resolveWorkspaceShortcuts(overrides), + capabilities: { + canSplitPanes: supportsDesktopPaneSplits() && !isCompact, + canOpenBrowserTabs: getIsElectron(), + }, + dispatch: (action) => { + clearCommandCenterFocusRestoreElement(); + keyboardActionDispatcher.dispatch(action); + }, + runGitAction, + }), + [gitActions, isCompact, overrides, runGitAction, t], + ); + + useCommandCenterActions({ + sourceId: "workspace", + enabled: Boolean(serverId && cwd), + actions, + }); +} + +export function CommandCenterWorkspaceActions() { + useWorkspaceCommandCenterActions(); + return null; +} diff --git a/packages/app/src/git/action-icons.tsx b/packages/app/src/git/action-icons.tsx new file mode 100644 index 000000000..6aaf5a635 --- /dev/null +++ b/packages/app/src/git/action-icons.tsx @@ -0,0 +1,31 @@ +import { withUnistyles } from "react-native-unistyles"; +import { + Archive, + ArrowDownUp, + Download, + GitCommitHorizontal, + GitMerge, + RefreshCcw, + Upload, +} from "lucide-react-native"; +import type { Theme } from "@/styles/theme"; + +const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); +const ThemedDownload = withUnistyles(Download); +const ThemedUpload = withUnistyles(Upload); +const ThemedArrowDownUp = withUnistyles(ArrowDownUp); +const ThemedGitMerge = withUnistyles(GitMerge); +const ThemedRefreshCcw = withUnistyles(RefreshCcw); +const ThemedArchive = withUnistyles(Archive); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export const GIT_ACTION_ICONS = { + commit: , + pull: , + push: , + pullAndPush: , + merge: , + mergeFromBase: , + archive: , +}; diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 1d589d237..39364f494 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -2,7 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useCallback, useMemo } from "react"; import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronDown, Info, MoreVertical } from "lucide-react-native"; +import { ChevronDown, MoreVertical } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { DropdownMenu, @@ -15,8 +15,8 @@ import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { useToast } from "@/contexts/toast-context"; import type { GitAction, GitActions } from "@/git/policy"; +import { useGitActionRunner } from "@/git/use-actions"; interface GitActionsSplitButtonProps { gitActions: GitActions; @@ -76,7 +76,7 @@ function GitActionMenuItem({ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const toast = useToast(); + const runGitAction = useGitActionRunner(); const archiveShortcutKeys = useShortcutKeys("archive-workspace"); const getActionDisplayLabel = useCallback((action: GitAction): string => { @@ -85,26 +85,12 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli return action.label; }, []); - const handleActionSelect = useCallback( - (action: GitAction) => { - if (action.unavailableMessage) { - toast.show(action.unavailableMessage, { - durationMs: 3200, - icon: , - }); - return; - } - action.handler(); - }, - [theme.colors.foreground, toast], - ); - const handlePrimaryPress = useCallback(() => { if (!gitActions.primary) { return; } - handleActionSelect(gitActions.primary); - }, [gitActions.primary, handleActionSelect]); + runGitAction(gitActions.primary); + }, [gitActions.primary, runGitAction]); const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []); @@ -172,7 +158,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli 0} @@ -205,7 +191,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli ))} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 9ddeeee43..7ca0b4d5c 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -30,22 +30,15 @@ import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { AlignJustify, - Archive, - ArrowDownUp, ChevronDown, Columns2, - Download, FolderTree, - GitCommitHorizontal, - GitMerge, List, ListChevronsDownUp, ListChevronsUpDown, Maximize2, Pilcrow, - RefreshCcw, RotateCw, - Upload, WrapText, } from "lucide-react-native"; import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; @@ -90,6 +83,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { BranchSwitcher } from "@/components/branch-switcher"; import { useGitActions } from "@/git/use-actions"; +import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import type { ForgeAuthState } from "@getpaseo/protocol/messages"; @@ -1330,13 +1324,6 @@ const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedList = withUnistyles(List); const ThemedMaximize2 = withUnistyles(Maximize2); -const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); -const ThemedDownload = withUnistyles(Download); -const ThemedUpload = withUnistyles(Upload); -const ThemedArrowDownUp = withUnistyles(ArrowDownUp); -const ThemedGitMerge = withUnistyles(GitMerge); -const ThemedRefreshCcw = withUnistyles(RefreshCcw); -const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); const DIFF_OPTIONS_WHITESPACE_ICON = ( @@ -2823,22 +2810,10 @@ export function GitDiffPane({ () => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")), [baseRef, t], ); - const gitActionsIcons = useMemo( - () => ({ - commit: , - pull: , - push: , - pullAndPush: , - merge: , - mergeFromBase: , - archive: , - }), - [], - ); const { gitActions, branchLabel } = useGitActions({ serverId, cwd, - icons: gitActionsIcons, + icons: GIT_ACTION_ICONS, }); const committedDiffDescription = useMemo( () => computeCommittedDiffDescription(branchLabel, baseRefLabel), diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 7b6837599..63f6c7425 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -1,4 +1,6 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; +import { Info } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; @@ -31,6 +33,26 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const ThemedInfo = withUnistyles(Info, (theme) => ({ color: theme.colors.foreground })); + +export function useGitActionRunner(): (action: GitAction) => void { + const toast = useToast(); + + return useCallback( + (action: GitAction) => { + if (action.disabled) return; + if (action.unavailableMessage) { + toast.show(action.unavailableMessage, { + durationMs: 3200, + icon: , + }); + return; + } + action.handler(); + }, + [toast], + ); +} /** * The leading icon for every change-request action (create/view/merge) is the diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index 0c087d02b..f5241b19a 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -1,16 +1,6 @@ -import { withUnistyles } from "react-native-unistyles"; -import { - Archive, - ArrowDownUp, - Download, - GitCommitHorizontal, - GitMerge, - RefreshCcw, - Upload, -} from "lucide-react-native"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { useGitActions } from "@/git/use-actions"; -import type { Theme } from "@/styles/theme"; +import { GIT_ACTION_ICONS } from "@/git/action-icons"; interface WorkspaceActionsProps { serverId: string; @@ -18,33 +8,11 @@ interface WorkspaceActionsProps { hideLabels?: boolean; } -const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); -const ThemedDownload = withUnistyles(Download); -const ThemedUpload = withUnistyles(Upload); -const ThemedArrowDownUp = withUnistyles(ArrowDownUp); -const ThemedGitMerge = withUnistyles(GitMerge); -const ThemedRefreshCcw = withUnistyles(RefreshCcw); -const ThemedArchive = withUnistyles(Archive); - -const mutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); - -const ICONS = { - commit: , - pull: , - push: , - pullAndPush: , - merge: , - mergeFromBase: , - archive: , -}; - export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { const { gitActions } = useGitActions({ serverId, cwd, - icons: ICONS, + icons: GIT_ACTION_ICONS, }); return ; diff --git a/packages/app/src/hooks/use-shortcut-keys.ts b/packages/app/src/hooks/use-shortcut-keys.ts index 4ed2a5dba..d1983cca9 100644 --- a/packages/app/src/hooks/use-shortcut-keys.ts +++ b/packages/app/src/hooks/use-shortcut-keys.ts @@ -1,7 +1,6 @@ import { useMemo } from "react"; import type { ShortcutKey } from "@/utils/format-shortcut"; -import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; -import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; +import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { getIsElectronRuntime } from "@/constants/layout"; @@ -12,16 +11,9 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null { const isDesktopApp = getIsElectronRuntime(); return useMemo(() => { - const platform = { isMac, isDesktop: isDesktopApp }; - const bindingId = getBindingIdForAction(actionId, platform); - if (!bindingId) return null; - - const override = overrides[bindingId]; - if (override) { - return chordStringToShortcutKeys(override); - } - - const defaultKeys = getDefaultKeysForAction(actionId, platform); - return defaultKeys ? [defaultKeys] : null; + return resolveShortcutKeysForAction(actionId, overrides, { + isMac, + isDesktop: isDesktopApp, + }); }, [actionId, overrides, isMac, isDesktopApp]); } diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index a5d1abe0d..e085190f4 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -27,6 +27,7 @@ export type KeyboardActionId = | "workspace.pane.close" | "workspace.focus.toggle" | "workspace.terminal.new" + | "workspace.browser.new" | "sidebar.toggle.right" | "workspace.new" | "workspace.project.pick" @@ -61,6 +62,7 @@ export type KeyboardActionDefinition = | { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.focus.toggle"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope } + | { id: "workspace.browser.new"; scope: KeyboardActionScope } | { id: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "workspace.new"; scope: KeyboardActionScope } | { id: "workspace.project.pick"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 2ab7f5234..3cec49723 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -6,6 +6,7 @@ import type { MessageInputKeyboardActionKind, } from "@/keyboard/actions"; import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string"; +import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; export type { KeyCombo } from "@/keyboard/shortcut-string"; @@ -1406,6 +1407,19 @@ export function getDefaultKeysForAction( return null; } +export function resolveShortcutKeysForAction( + actionId: string, + overrides: Readonly>, + platform: { isMac: boolean; isDesktop: boolean }, +): ShortcutKey[][] | null { + const bindingId = getBindingIdForAction(actionId, platform); + if (!bindingId) return null; + const override = overrides[bindingId]; + if (override) return chordStringToShortcutKeys(override); + const defaultKeys = getDefaultKeysForAction(actionId, platform); + return defaultKeys ? [defaultKeys] : null; +} + /** * The `KeyboardEvent.key` whose hold reveals the sidebar workspace-jump number * badges. It must match the modifier of the active `workspace.navigate.index` diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 0a89fe302..025ae8003 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3048,6 +3048,9 @@ function WorkspaceScreenContent({ case "workspace.terminal.new": handleCreateTerminal(); return true; + case "workspace.browser.new": + handleCreateBrowserTab(); + return true; case "workspace.tab.close-current": if (activeTabId) { void handleCloseTabById(activeTabId); @@ -3080,6 +3083,7 @@ function WorkspaceScreenContent({ activeTabId, handleCloseTabById, handleCreateDraftTab, + handleCreateBrowserTab, handleCreateTerminal, navigateToTabId, tabs, @@ -3194,6 +3198,7 @@ function WorkspaceScreenContent({ "workspace.tab.navigate-index", "workspace.tab.navigate-relative", "workspace.terminal.new", + "workspace.browser.new", ] as const, enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), priority: 100,