From 3ef4a423056789763a0ee756dc80e9b45f0c9e36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 24 May 2026 20:05:46 -0700 Subject: [PATCH] Add Cmd-J settings and quick actions (#2769) Co-authored-by: Orca --- src/renderer/src/components/Terminal.tsx | 76 +-- .../src/components/WorktreeJumpPalette.tsx | 384 +++++++++++---- .../components/cmd-j/palette-results.test.ts | 153 ++++++ .../src/components/cmd-j/palette-results.ts | 214 +++++++++ .../cmd-j/quick-action-context.test.ts | 227 +++++++++ .../components/cmd-j/quick-action-context.ts | 166 +++++++ .../src/components/cmd-j/quick-actions.ts | 103 ++++ .../components/settings/AppearancePane.tsx | 149 +----- .../src/components/settings/BrowserPane.tsx | 7 +- .../components/settings/ComputerUsePane.tsx | 17 +- .../settings/DeveloperPermissionsPane.tsx | 30 +- .../src/components/settings/InputPane.tsx | 23 +- .../components/settings/IntegrationsPane.tsx | 35 +- .../src/components/settings/MobilePane.tsx | 50 +- .../settings/MobileSettingsPane.tsx | 32 +- .../components/settings/NotificationsPane.tsx | 53 +-- .../settings/QuickCommandsPane.test.ts | 12 + .../components/settings/QuickCommandsPane.tsx | 30 +- .../components/settings/RepositoryPane.tsx | 155 +----- .../src/components/settings/Settings.tsx | 450 +++--------------- .../src/components/settings/ShortcutsPane.tsx | 38 +- .../src/components/settings/SshPane.tsx | 25 +- .../components/settings/appearance-search.ts | 135 ++++++ .../settings/browser-pane-search.ts | 8 + .../settings/computer-use-search.ts | 16 + .../settings/developer-permissions-search.ts | 29 ++ .../src/components/settings/input-search.ts | 22 + .../settings/integrations-search.ts | 34 ++ .../components/settings/mobile-pane-search.ts | 49 ++ .../settings/mobile-settings-search.ts | 25 + .../settings/notifications-search.ts | 52 ++ .../components/settings/repository-search.ts | 154 ++++++ .../components/settings/shortcuts-search.ts | 34 ++ .../src/components/settings/ssh-search.ts | 24 + .../src/components/stats/StatsPane.tsx | 24 +- .../src/components/stats/stats-search.ts | 23 + .../tab-group/useTabGroupWorkspaceModel.ts | 73 +-- .../useSettingsNavigationMetadata.test.ts | 71 +++ .../hooks/useSettingsNavigationMetadata.ts | 331 +++++++++++++ .../src/lib/settings-navigation-types.ts | 45 ++ src/renderer/src/store/slices/browser.ts | 29 ++ .../store/slices/cmd-j-create-actions.test.ts | 75 +++ src/renderer/src/store/slices/editor.ts | 28 ++ src/renderer/src/store/slices/terminals.ts | 48 ++ src/renderer/src/store/slices/ui.ts | 10 +- 45 files changed, 2610 insertions(+), 1158 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-results.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-results.ts create mode 100644 src/renderer/src/components/cmd-j/quick-action-context.test.ts create mode 100644 src/renderer/src/components/cmd-j/quick-action-context.ts create mode 100644 src/renderer/src/components/cmd-j/quick-actions.ts create mode 100644 src/renderer/src/components/settings/QuickCommandsPane.test.ts create mode 100644 src/renderer/src/components/settings/appearance-search.ts create mode 100644 src/renderer/src/components/settings/browser-pane-search.ts create mode 100644 src/renderer/src/components/settings/computer-use-search.ts create mode 100644 src/renderer/src/components/settings/developer-permissions-search.ts create mode 100644 src/renderer/src/components/settings/input-search.ts create mode 100644 src/renderer/src/components/settings/integrations-search.ts create mode 100644 src/renderer/src/components/settings/mobile-pane-search.ts create mode 100644 src/renderer/src/components/settings/mobile-settings-search.ts create mode 100644 src/renderer/src/components/settings/notifications-search.ts create mode 100644 src/renderer/src/components/settings/repository-search.ts create mode 100644 src/renderer/src/components/settings/shortcuts-search.ts create mode 100644 src/renderer/src/components/settings/ssh-search.ts create mode 100644 src/renderer/src/components/stats/stats-search.ts create mode 100644 src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts create mode 100644 src/renderer/src/hooks/useSettingsNavigationMetadata.ts create mode 100644 src/renderer/src/lib/settings-navigation-types.ts create mode 100644 src/renderer/src/store/slices/cmd-j-create-actions.test.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 88acbca21..27839d88b 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -10,9 +10,7 @@ import { } from '@/constants/terminal' import { useAppStore } from '../store' import { useAllWorktrees } from '../store/selectors' -import { createUntitledMarkdownFile } from '../lib/create-untitled-markdown' import { getConnectionId } from '../lib/connection-context' -import { extractIpcErrorMessage } from '../lib/ipc-error' import { basename } from '../lib/path' import { Dialog, @@ -53,11 +51,11 @@ import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair' import { addBackgroundMountedTerminalWorktree } from './terminal/background-terminal-worktree-mount' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import CodexRestartChip from './CodexRestartChip' import { @@ -128,11 +126,17 @@ function Terminal(): React.JSX.Element | null { ) const setActiveTabType = useAppStore((s) => s.setActiveTabType) const setActiveFile = useAppStore((s) => s.setActiveFile) - const openFile = useAppStore((s) => s.openFile) const closeFile = useAppStore((s) => s.closeFile) const pinFile = useAppStore((s) => s.pinFile) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const createBrowserTab = useAppStore((s) => s.createBrowserTab) + const openNewBrowserTabInActiveWorkspace = useAppStore( + (s) => s.openNewBrowserTabInActiveWorkspace + ) + const openNewMarkdownInActiveWorkspace = useAppStore((s) => s.openNewMarkdownInActiveWorkspace) + const openNewTerminalTabInActiveWorkspace = useAppStore( + (s) => s.openNewTerminalTabInActiveWorkspace + ) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab) const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) @@ -643,6 +647,13 @@ function Terminal(): React.JSX.Element | null { if (!activeWorktreeId) { return } + const targetGroupId = + useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? + useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id + if (!shellOverride && targetGroupId) { + void openNewTerminalTabInActiveWorkspace(targetGroupId) + return + } if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { void createWebRuntimeSessionTerminal({ worktreeId: activeWorktreeId, @@ -679,20 +690,31 @@ function Terminal(): React.JSX.Element | null { const order = base.filter((id) => id !== newTab.id) order.push(newTab.id) setTabBarOrder(activeWorktreeId, order) - // Why: keyboard (Cmd/Ctrl+T) creation should leave the user ready to type - // in the new shell. Without an explicit focus call, the window-level - // keydown handler keeps focus on whatever surface dispatched the shortcut - // (often ), so the first keystroke is dropped instead of reaching - // the new xterm. Matches the "+" menu path in TabBar.tsx. + // Why: shell-specific creation still uses the legacy path; keep the + // keyboard shortcut focused until the lifted action accepts shell overrides. focusTerminalTabSurface(newTab.id) }, - [activeRuntimeEnvironmentId, activeWorktreeId, createTab, setActiveTabType, setTabBarOrder] + [ + activeRuntimeEnvironmentId, + activeWorktreeId, + createTab, + openNewTerminalTabInActiveWorkspace, + setActiveTabType, + setTabBarOrder + ] ) const handleNewBrowserTab = useCallback(() => { if (!activeWorktreeId) { return } + const targetGroupId = + useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? + useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id + if (targetGroupId) { + void openNewBrowserTabInActiveWorkspace(targetGroupId) + return + } const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) { void createWebRuntimeSessionBrowserTab({ @@ -706,7 +728,12 @@ function Terminal(): React.JSX.Element | null { title: 'New Browser Tab', focusAddressBar: true }) - }, [activeRuntimeEnvironmentId, activeWorktreeId, createBrowserTab]) + }, [ + activeRuntimeEnvironmentId, + activeWorktreeId, + createBrowserTab, + openNewBrowserTabInActiveWorkspace + ]) const handleDuplicateBrowserTab = useCallback( (browserTabId: string) => { @@ -740,29 +767,14 @@ function Terminal(): React.JSX.Element | null { if (!activeWorktreeId) { return } - const worktree = useAppStore.getState().getKnownWorktreeById(activeWorktreeId) - if (!worktree) { + const targetGroupId = + useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? + useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id + if (!targetGroupId) { return } - try { - // Why: the global Cmd/Ctrl+Shift+M shortcut is handled here rather than - // inside a specific TabGroupPanel, so it must snapshot the store's - // current focused group explicitly. Otherwise split layouts fall back to - // the ambient/default group and open the file in the wrong pane. - const targetGroupId = useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - const settings = useAppStore.getState().settings - const fileInfo = await createUntitledMarkdownFile( - worktree.path, - activeWorktreeId, - connectionId, - settings - ) - openFile(fileInfo, { preview: false, targetGroupId }) - } catch (err) { - toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.')) - } - }, [activeWorktreeId, openFile]) + await openNewMarkdownInActiveWorkspace(targetGroupId) + }, [activeWorktreeId, openNewMarkdownInActiveWorkspace]) const handleCloseTab = useCallback( (tabId: string) => { diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 90aaf7d2f..2396dab3b 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -48,6 +48,22 @@ import { queueBrowserFocusRequest } from '@/components/browser-pane/browser-focus' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata' +import { + buildCmdJActionResults, + buildCmdJSettingsResults, + rankCmdJMiddleResults, + type CmdJActionResult, + type CmdJSettingsResult +} from '@/components/cmd-j/palette-results' +import { + buildCmdJQuickActionContext, + captureCmdJActiveGroupSnapshot, + getUnavailableQuickActionMessage, + type CmdJActiveGroupSnapshot +} from '@/components/cmd-j/quick-action-context' +import { CMD_J_QUICK_ACTIONS } from '@/components/cmd-j/quick-actions' +import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' import { isGitRepoKind } from '../../../shared/repo-kind' @@ -64,6 +80,18 @@ type BrowserPaletteItem = { result: BrowserPaletteSearchResult } +type SettingsPaletteItem = { + id: string + type: 'settings' + result: CmdJSettingsResult +} + +type QuickActionPaletteItem = { + id: string + type: 'quick-action' + result: CmdJActionResult +} + type SectionHeader = { id: string type: 'section-header' @@ -76,9 +104,20 @@ type HintRow = { label: string } -type PaletteItem = WorktreePaletteItem | BrowserPaletteItem +type CreateWorktreePaletteItem = { + id: typeof CREATE_WORKTREE_ITEM_ID + type: 'create-worktree' +} -type PaletteListEntry = PaletteItem | SectionHeader | HintRow +// Why: Cmd+J is a fast intent surface, not a dump of every setup button. +// Keep future quick actions curated; route one-time setup flows through Settings. +type PaletteItem = + | WorktreePaletteItem + | SettingsPaletteItem + | QuickActionPaletteItem + | BrowserPaletteItem + +type PaletteListEntry = PaletteItem | CreateWorktreePaletteItem | SectionHeader | HintRow type BrowserSelection = { worktree: Worktree @@ -148,10 +187,22 @@ function findBrowserSelection( return { page, workspace, worktree } } +function getSettingsTargetFromSectionId(sectionId: string): { + pane: SettingsNavTarget + repoId: string | null +} { + if (sectionId.startsWith('repo-')) { + return { pane: 'repo', repoId: sectionId.slice('repo-'.length) } + } + return { pane: sectionId as SettingsNavTarget, repoId: null } +} + export default function WorktreeJumpPalette(): React.JSX.Element | null { const visible = useAppStore((s) => s.activeModal === 'worktree-palette') const closeModal = useAppStore((s) => s.closeModal) const openModal = useAppStore((s) => s.openModal) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const allWorktrees = useAllWorktrees() const repos = useAppStore((s) => s.repos) @@ -176,11 +227,22 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) + useAppStore((s) => s.activeGroupIdByWorktree) + useAppStore((s) => s.groupsByWorktree) + useAppStore((s) => s.settings?.activeRuntimeEnvironmentId) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null) + const openNewBrowserTabInActiveWorkspace = useAppStore( + (s) => s.openNewBrowserTabInActiveWorkspace + ) + const openNewMarkdownInActiveWorkspace = useAppStore((s) => s.openNewMarkdownInActiveWorkspace) + const openNewTerminalTabInActiveWorkspace = useAppStore( + (s) => s.openNewTerminalTabInActiveWorkspace + ) + const settingsSections = useSettingsNavigationMetadata() const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) @@ -189,6 +251,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal') const previousBrowserPageIdRef = useRef(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') + const activeGroupSnapshotRef = useRef(null) const wasVisibleRef = useRef(false) const skipRestoreFocusRef = useRef(false) const prevQueryRef = useRef('') @@ -200,6 +263,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const canCreateWorktree = repos.length > 0 const hasQuery = deferredQuery.trim().length > 0 + const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 // Why: the empty-query palette mirrors sidebar filters so opening Search // starts from the same quiet list. Typed search switches to the global @@ -411,6 +475,61 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [browserMatches] ) + const settingsResults = useMemo( + () => buildCmdJSettingsResults(settingsSections), + [settingsSections] + ) + const actionResults = useMemo(() => buildCmdJActionResults(CMD_J_QUICK_ACTIONS), []) + + const openCreateWorkspaceAction = useCallback(() => { + queueMicrotask(() => + openModal('new-workspace-composer', { telemetrySource: 'command_palette' }) + ) + }, [openModal]) + + const openAddQuickCommandAction = useCallback(() => { + openSettingsTarget({ pane: 'quick-commands', repoId: null, intent: 'add-quick-command' }) + openSettingsPage() + }, [openSettingsPage, openSettingsTarget]) + + const buildQuickActionContext = useCallback( + () => + buildCmdJQuickActionContext({ + state: useAppStore.getState(), + activeGroupSnapshot: activeGroupSnapshotRef.current, + openNewBrowserTab: openNewBrowserTabInActiveWorkspace, + openNewMarkdownFile: openNewMarkdownInActiveWorkspace, + openNewTerminalTab: openNewTerminalTabInActiveWorkspace, + openCreateWorkspace: openCreateWorkspaceAction, + openAddQuickCommand: openAddQuickCommandAction + }), + [ + openAddQuickCommandAction, + openCreateWorkspaceAction, + openNewBrowserTabInActiveWorkspace, + openNewMarkdownInActiveWorkspace, + openNewTerminalTabInActiveWorkspace + ] + ) + + const quickActionContext = buildQuickActionContext() + + const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>( + () => + rankCmdJMiddleResults({ + query: deferredQuery, + settingsResults, + actionResults: actionResults.filter( + (action) => action.isAvailable(quickActionContext).available + ) + }).map((result) => + result.kind === 'settings' + ? { id: result.id, type: 'settings' as const, result } + : { id: `quick-action:${result.id}`, type: 'quick-action' as const, result } + ), + [actionResults, deferredQuery, quickActionContext, settingsResults] + ) + // Why: on empty query we cap the worktree section (not browser tabs) so the // BROWSER TABS header + ≥1 page row stays visible above the fold — users // with 30+ worktrees would otherwise never see browser pages. The cap is @@ -422,68 +541,34 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const EMPTY_QUERY_WORKTREE_CAP = 5 const EMPTY_QUERY_BROWSER_CAP = 5 - const listEntries = useMemo(() => { - const entries: PaletteListEntry[] = [] - + const paletteSections = useMemo(() => { // Why: the worktree cap only earns its keep when there are browser tabs // to protect above-the-fold. With zero browser pages, capping would force // the user to type for no reason — uncap so the recent list fills the // viewport naturally. const worktreeCap = !hasQuery && browserItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity const visibleWorktreeItems = hasQuery ? worktreeItems : worktreeItems.slice(0, worktreeCap) + const visibleMiddleItems = hasQuery ? middleItems : [] const visibleBrowserItems = hasQuery ? browserItems : browserItems.slice(0, EMPTY_QUERY_BROWSER_CAP) - - // Header rule: on empty query each section is categorically distinct - // (worktrees vs. tabs), so a lone header is a useful signpost. On query, - // suppress headers unless both sections are populated — otherwise a lone - // header above one list is noise. - const showWorktreeHeader = hasQuery - ? visibleWorktreeItems.length > 0 && visibleBrowserItems.length > 0 - : visibleWorktreeItems.length > 0 - const showBrowserHeader = hasQuery - ? visibleWorktreeItems.length > 0 && visibleBrowserItems.length > 0 - : visibleBrowserItems.length > 0 - - // Why: only surface the hint when there's actually something hidden, - // otherwise the row would be a lie. const showWorktreeHint = !hasQuery && worktreeItems.length > worktreeCap - if (visibleWorktreeItems.length > 0) { - if (showWorktreeHeader) { - entries.push({ - id: '__header_worktrees__', - type: 'section-header', - label: hasQuery ? 'Workspaces' : 'Recent Workspaces' - }) - } - entries.push(...visibleWorktreeItems) - if (showWorktreeHint) { - entries.push({ - id: '__hint_worktree_cap__', - type: 'hint', - label: `Type to see all ${worktreeItems.length} workspaces` - }) - } + return { + visibleWorktreeItems, + visibleMiddleItems, + visibleBrowserItems, + showWorktreeHint } - if (visibleBrowserItems.length > 0) { - if (showBrowserHeader) { - entries.push({ - id: '__header_browser__', - type: 'section-header', - label: 'Browser Tabs' - }) - } - entries.push(...visibleBrowserItems) - } - return entries - }, [worktreeItems, browserItems, hasQuery]) + }, [worktreeItems, middleItems, browserItems, hasQuery]) const selectableItems = useMemo( - () => - listEntries.filter((e): e is PaletteItem => e.type !== 'section-header' && e.type !== 'hint'), - [listEntries] + () => [ + ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleMiddleItems, + ...paletteSections.visibleBrowserItems + ], + [paletteSections] ) const selectableItemIds = useMemo(() => selectableItems.map((item) => item.id), [selectableItems]) @@ -498,7 +583,74 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [canCreateWorktree, deferredQuery, selectableItemIds] ) - const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 + const listEntries = useMemo(() => { + const entries: PaletteListEntry[] = [] + const { visibleWorktreeItems, visibleMiddleItems, visibleBrowserItems, showWorktreeHint } = + paletteSections + const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0) + const populatedSectionCount = [ + visibleWorkspaceItemCount, + visibleMiddleItems.length, + visibleBrowserItems.length + ].filter((count) => count > 0).length + + // Header rule: on empty query each section is categorically distinct + // (worktrees vs. tabs), so a lone header is a useful signpost. On query, + // suppress headers unless both sections are populated — otherwise a lone + // header above one list is noise. + const showWorktreeHeader = hasQuery + ? visibleWorkspaceItemCount > 0 && populatedSectionCount > 1 + : visibleWorktreeItems.length > 0 + const showBrowserHeader = hasQuery + ? visibleBrowserItems.length > 0 && populatedSectionCount > 1 + : visibleBrowserItems.length > 0 + const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1 + + if (visibleWorkspaceItemCount > 0) { + if (showWorktreeHeader) { + entries.push({ + id: '__header_worktrees__', + type: 'section-header', + label: hasQuery ? 'Workspaces' : 'Recent Workspaces' + }) + } + entries.push(...visibleWorktreeItems) + if (showCreateAction) { + // Why: the typed create affordance is workspace-scoped, so keep it + // directly under workspace matches instead of after actions/tabs. + entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' }) + } + if (showWorktreeHint) { + entries.push({ + id: '__hint_worktree_cap__', + type: 'hint', + label: `Type to see all ${worktreeItems.length} workspaces` + }) + } + } + if (visibleMiddleItems.length > 0) { + if (showMiddleHeader) { + entries.push({ + id: '__header_actions_settings__', + type: 'section-header', + label: 'Actions & Settings' + }) + } + entries.push(...visibleMiddleItems) + } + if (visibleBrowserItems.length > 0) { + if (showBrowserHeader) { + entries.push({ + id: '__header_browser__', + type: 'section-header', + label: 'Browser Tabs' + }) + } + entries.push(...visibleBrowserItems) + } + return entries + }, [hasQuery, paletteSections, showCreateAction, worktreeItems.length]) + // Why: empty-state / "has any worktrees?" uses the full visible list // (including current) so the palette never claims to be empty just // because the only visible worktree is the currently active one. @@ -506,10 +658,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hasAnyWorktrees = visibleWorktreesForState.length > 0 const hasAnySearchableWorktrees = hasQuery ? searchScopeWorktrees.length > 0 : hasAnyWorktrees const hasAnyBrowserPages = browserPageEntries.length > 0 + const hasAnyMiddleResults = middleItems.length > 0 useEffect(() => { if (visible && !wasVisibleRef.current) { createLookupGuard.invalidate() + activeGroupSnapshotRef.current = captureCmdJActiveGroupSnapshot( + useAppStore.getState(), + activeWorktreeId + ) previousWorktreeIdRef.current = activeWorktreeId previousActiveTabTypeRef.current = activeTabType previousBrowserPageIdRef.current = @@ -541,6 +698,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } else { createLookupGuard.invalidate() } + activeGroupSnapshotRef.current = null } wasVisibleRef.current = visible @@ -679,15 +837,46 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [closeModal, requestBrowserFocus] ) + const handleSelectSettings = useCallback( + (result: CmdJSettingsResult) => { + const target = getSettingsTargetFromSectionId(result.sectionId) + skipRestoreFocusRef.current = true + closeModal() + setSelectedItemId('') + openSettingsTarget(target) + openSettingsPage() + }, + [closeModal, openSettingsPage, openSettingsTarget] + ) + + const handleSelectQuickAction = useCallback( + (action: CmdJActionResult) => { + skipRestoreFocusRef.current = true + closeModal() + setSelectedItemId('') + const ctx = buildQuickActionContext() + void action.run(ctx).then((result) => { + if (result.status === 'unavailable') { + toast.error(getUnavailableQuickActionMessage(action.title, result.reason)) + } + }) + }, + [buildQuickActionContext, closeModal] + ) + const handleSelectItem = useCallback( (item: PaletteItem) => { if (item.type === 'worktree') { handleSelectWorktree(item.worktree.id) - } else { + } else if (item.type === 'browser-page') { handleSelectBrowserPage(item.result) + } else if (item.type === 'settings') { + handleSelectSettings(item.result) + } else { + handleSelectQuickAction(item.result) } }, - [handleSelectBrowserPage, handleSelectWorktree] + [handleSelectBrowserPage, handleSelectQuickAction, handleSelectSettings, handleSelectWorktree] ) const handleCreateWorktree = useCallback(() => { @@ -862,10 +1051,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const resultCount = selectableItems.length const emptyState = (() => { - if ((hasAnySearchableWorktrees || hasAnyBrowserPages) && hasQuery) { + if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyBrowserPages) && hasQuery) { return { title: 'No results match your search', - subtitle: 'Try a name, branch, repo, port, comment, PR, page title, or URL.' + subtitle: 'Try a workspace, setting, action, page title, URL, PR, or port.' } } // Why: empty-query rows exclude the current worktree, so a single-worktree @@ -875,12 +1064,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { if (!hasQuery && hasAnyWorktrees && !hasAnyBrowserPages) { return { title: 'No other worktrees to switch to', - subtitle: 'Type to search or create a new worktree.' + subtitle: 'Type to search workspaces, settings, tabs, and actions.' } } return { - title: 'No active worktrees or browser tabs', - subtitle: 'Create a worktree or open a page in Orca to get started.' + title: 'No active worktrees, settings, actions, or browser tabs', + subtitle: 'Create a workspace or open a page in Orca to get started.' } })() @@ -892,7 +1081,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { onOpenAutoFocus={handleOpenAutoFocus} onCloseAutoFocus={handleCloseAutoFocus} title="Jump to..." - description="Search worktrees and browser tabs" + description="Search workspaces, settings, tabs, and actions" overlayClassName="bg-black/55 backdrop-blur-[2px]" contentClassName="top-[13%] w-[736px] max-w-[94vw] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl" commandProps={{ @@ -903,7 +1092,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }} > - {isLoading ? ( + {isLoading && selectableItems.length === 0 && !showCreateAction ? ( +
+
+
+
+ {`Create workspace "${createWorktreeName}"`} +
+
+ + ) + } + if (entry.type === 'worktree') { const worktree = entry.worktree const repo = repoMap.get(worktree.repoId) @@ -1069,6 +1278,40 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) } + if (entry.type === 'settings' || entry.type === 'quick-action') { + const result = entry.result + const Icon = result.icon + const kindLabel = entry.type === 'settings' ? 'Settings' : 'Action' + return ( + handleSelectItem(entry)} + className={cn( + 'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]', + 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' + )} + > +
+
+
+
+ + {result.title} + + + {kindLabel} + +
+
+ {result.description} +
+
+
+ ) + } + const result = entry.result const browserWorktree = worktreeMap.get(result.worktreeId) const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined @@ -1138,25 +1381,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) })} - {showCreateAction && ( - // Why: render the create action last so cmdk does not briefly - // auto-select it before our effect promotes the first real match - // when the query only matches browser pages. - -
-
-
-
- {`Create workspace "${createWorktreeName}"`} -
-
-
- )} )}
diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts new file mode 100644 index 000000000..3ad5928f4 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { Globe, Settings } from 'lucide-react' +import type { CmdJQuickAction } from './quick-actions' +import { + buildCmdJActionResults, + buildCmdJSettingsResults, + rankCmdJMiddleResults +} from './palette-results' +import type { SettingsNavSection } from '@/lib/settings-navigation-types' + +const noopRun: CmdJQuickAction['run'] = async () => ({ status: 'ok' }) +const available: CmdJQuickAction['isAvailable'] = () => ({ available: true }) + +const actions: CmdJQuickAction[] = [ + { + id: 'new-browser-tab', + kind: 'action', + title: 'New Browser Tab', + description: 'Open a browser tab.', + icon: Globe, + verbKeywords: ['new browser', 'new browser tab'], + isAvailable: available, + run: noopRun + }, + { + id: 'new-terminal-tab', + kind: 'action', + title: 'New Terminal Tab', + description: 'Open a terminal tab.', + icon: Globe, + verbKeywords: ['new terminal', 'new terminal tab'], + isAvailable: available, + run: noopRun + }, + { + id: 'new-markdown-file', + kind: 'action', + title: 'New Markdown File', + description: 'Create markdown.', + icon: Globe, + verbKeywords: ['new markdown', 'new mark'], + isAvailable: available, + run: noopRun + }, + { + id: 'create-workspace', + kind: 'action', + title: 'Create Workspace', + description: 'Create workspace.', + icon: Globe, + verbKeywords: ['create workspace', 'add workspace', 'new workspace'], + isAvailable: available, + run: noopRun + }, + { + id: 'add-quick-command', + kind: 'action', + title: 'Add Quick Command', + description: 'Create a saved terminal command.', + icon: Globe, + verbKeywords: ['add quick command', 'new quick command'], + isAvailable: available, + run: noopRun + } +] + +const sections: SettingsNavSection[] = [ + { + id: 'terminal', + title: 'Terminal', + description: 'Shell configuration.', + icon: Settings, + searchEntries: [{ title: 'Terminal Font' }], + group: 'workflows' + }, + { + id: 'browser', + title: 'Browser', + description: 'Cookie import setup.', + icon: Settings, + searchEntries: [{ title: 'Default Browser URL' }], + group: 'workflows' + }, + { + id: 'ssh', + title: 'SSH Hosts', + description: 'Remote hosts.', + icon: Settings, + searchEntries: [{ title: 'Remote Shell' }], + group: 'remote' + }, + { + id: 'appearance', + title: 'Appearance', + description: 'Theme and chrome.', + icon: Settings, + searchEntries: [{ title: 'Theme' }], + group: 'interface' + }, + { + id: 'agents', + title: 'Agents', + description: 'Manage AI agents.', + icon: Settings, + searchEntries: [{ title: 'Default Agent' }], + group: 'setup' + }, + { + id: 'quick-commands', + title: 'Quick Commands', + description: 'Saved commands.', + icon: Settings, + searchEntries: [{ title: 'Command Scope' }], + group: 'workflows' + } +] + +function top(query: string): string | undefined { + return rankCmdJMiddleResults({ + query, + settingsResults: buildCmdJSettingsResults(sections), + actionResults: buildCmdJActionResults(actions) + })[0]?.id +} + +describe('Cmd+J palette middle-band ranking', () => { + it.each([ + ['new terminal', 'new-terminal-tab'], + ['new markdown', 'new-markdown-file'], + ['new browser', 'new-browser-tab'], + ['create workspace', 'create-workspace'], + ['add workspace', 'create-workspace'], + ['new workspace', 'create-workspace'], + ['terminal settings', 'settings:terminal'], + ['browser settings', 'settings:browser'], + ['ssh', 'settings:ssh'], + ['agents', 'settings:agents'], + ['new terminal settings', 'settings:terminal'], + ['new mark', 'new-markdown-file'], + ['appear', 'settings:appearance'], + ['terminal', 'settings:terminal'], + ['browser', 'settings:browser'], + ['quick commands', 'settings:quick-commands'], + ['add quick command', 'add-quick-command'] + ])('ranks %s first', (query, expectedId) => { + expect(top(query)).toBe(expectedId) + }) + + it('does not match settings on one-character or description-only queries', () => { + expect(top('t')).toBeUndefined() + expect(top('cookie import')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts new file mode 100644 index 000000000..90056df6a --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -0,0 +1,214 @@ +import type { LucideIcon } from 'lucide-react' +import type { SettingsNavSection } from '@/lib/settings-navigation-types' +import type { CmdJQuickAction } from './quick-actions' + +export type CmdJSettingsResult = { + id: string + kind: 'settings' + title: string + description: string + icon: LucideIcon + sectionId: string + order: number + configKeywords: string[] +} + +export type CmdJActionResult = CmdJQuickAction & { + order: number +} + +export type CmdJMiddleResult = CmdJSettingsResult | CmdJActionResult + +type RankedResult = { + result: CmdJMiddleResult + rule: number + score: number +} + +const SETTINGS_ALIASES: Record = { + browser: ['browser settings'], + terminal: ['terminal settings'], + ssh: ['ssh'], + shortcuts: ['keyboard shortcuts'], + appearance: ['theme', 'themes'], + agents: ['ai agents'], + 'quick-commands': ['quick commands', 'quick command'], + repo: ['repository settings', 'project settings'], + integrations: ['gitlab', 'github', 'linear'], + notifications: ['notification settings'], + mobile: ['phone'], + voice: ['dictation'], + 'computer-use': ['computer use'], + stats: ['usage'], + privacy: ['telemetry'] +} + +function normalizeQuery(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ' ') +} + +function keywordParts(section: SettingsNavSection): string[] { + const baseId = section.id.startsWith('repo-') ? 'repo' : section.id + const idWords = baseId.replace(/-/g, ' ') + return [ + section.id, + baseId, + idWords, + section.title, + `${section.title} settings`, + `${idWords} settings`, + ...(SETTINGS_ALIASES[baseId] ?? []), + ...section.searchEntries.map((entry) => entry.title) + ] +} + +function uniqueNormalized(values: readonly string[]): string[] { + return [...new Set(values.map(normalizeQuery).filter(Boolean))] +} + +export function buildCmdJSettingsResults( + sections: readonly SettingsNavSection[] +): CmdJSettingsResult[] { + return sections.map((section, order) => ({ + id: `settings:${section.id}`, + kind: 'settings', + title: section.title, + description: section.description, + icon: section.icon, + sectionId: section.id, + order, + configKeywords: uniqueNormalized(keywordParts(section)) + })) +} + +export function buildCmdJActionResults(actions: readonly CmdJQuickAction[]): CmdJActionResult[] { + return actions.map((action, order) => ({ ...action, order })) +} + +function startsOrIsStartedBy(query: string, keyword: string): boolean { + return keyword.startsWith(query) || query.startsWith(keyword) +} + +function tokenize(value: string): string[] { + return normalizeQuery(value) + .split(/[^a-z0-9]+/) + .filter(Boolean) +} + +function tokenScore(query: string, values: readonly string[]): number { + const candidateTokens = values.flatMap(tokenize) + if (candidateTokens.length === 0) { + return 0 + } + + let score = 0 + for (const queryToken of tokenize(query)) { + let best = 0 + for (const candidateToken of candidateTokens) { + if (candidateToken === queryToken) { + best = Math.max(best, 3) + } else if (candidateToken.startsWith(queryToken)) { + best = Math.max(best, 2) + } else if (candidateToken.includes(queryToken)) { + best = Math.max(best, 1) + } + } + score += best + } + return score +} + +function rankingForCandidate( + query: string, + candidate: CmdJMiddleResult, + actionVerbKeywords: readonly string[], + settingsConfigKeywords: readonly string[] +): RankedResult | null { + if (!query) { + return null + } + + if (candidate.kind === 'action' && candidate.verbKeywords.some((keyword) => query === keyword)) { + return { result: candidate, rule: 1, score: 0 } + } + + if ( + candidate.kind === 'settings' && + candidate.configKeywords.some((keyword) => query === keyword) + ) { + return { result: candidate, rule: 2, score: 0 } + } + + if ( + candidate.kind === 'settings' && + actionVerbKeywords.some((keyword) => query.startsWith(keyword)) && + candidate.configKeywords.some((keyword) => query.endsWith(keyword)) + ) { + return { result: candidate, rule: 3, score: 0 } + } + + if ( + candidate.kind === 'action' && + candidate.verbKeywords.some((keyword) => startsOrIsStartedBy(query, keyword)) && + !settingsConfigKeywords.some((keyword) => query.endsWith(keyword)) + ) { + return { result: candidate, rule: 4, score: 0 } + } + + if ( + candidate.kind === 'settings' && + candidate.configKeywords.some((keyword) => keyword.startsWith(query) && keyword !== query) + ) { + return { result: candidate, rule: 5, score: 0 } + } + + const values = + candidate.kind === 'settings' + ? [candidate.title, ...candidate.configKeywords] + : [candidate.title, ...candidate.verbKeywords] + const score = tokenScore(query, values) + return score > 0 ? { result: candidate, rule: 6, score } : null +} + +function compareRanked(a: RankedResult, b: RankedResult): number { + if (a.rule !== b.rule) { + return a.rule - b.rule + } + if (a.rule === 6 && a.score !== b.score) { + return b.score - a.score + } + if (a.result.kind !== b.result.kind) { + return a.result.kind === 'settings' ? -1 : 1 + } + if (a.result.order !== b.result.order) { + return a.result.order - b.result.order + } + return a.result.id.localeCompare(b.result.id) +} + +export function rankCmdJMiddleResults({ + query, + settingsResults, + actionResults +}: { + query: string + settingsResults: readonly CmdJSettingsResult[] + actionResults: readonly CmdJActionResult[] +}): CmdJMiddleResult[] { + const normalizedQuery = normalizeQuery(query) + if (normalizedQuery.length < 2) { + return [] + } + const settings = settingsResults + const actions = actionResults + const actionVerbKeywords = actions.flatMap((action) => action.verbKeywords) + const settingsConfigKeywords = settings.flatMap((setting) => setting.configKeywords) + + return [...settings, ...actions] + .map((candidate) => + rankingForCandidate(normalizedQuery, candidate, actionVerbKeywords, settingsConfigKeywords) + ) + .filter((entry): entry is RankedResult => entry !== null) + .sort(compareRanked) + .map((entry) => entry.result) +} diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts new file mode 100644 index 000000000..8659c4470 --- /dev/null +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest' +import { + buildCmdJQuickActionContext, + getWorkspaceScopedActionAvailability, + resolveCmdJActiveGroupId, + type CmdJQuickActionContext +} from './quick-action-context' +import { CMD_J_QUICK_ACTIONS } from './quick-actions' +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../shared/types' + +type GroupState = Pick + +function ctx( + overrides: Partial< + Pick + > +): Pick { + return { + activeGroupId: 'group-1', + activeWorktreeId: 'wt-1', + isLoading: false, + sshStatus: null, + ...overrides + } +} + +describe('Cmd+J quick action context', () => { + it('resolves the snapshot group, then falls back when stale or missing', () => { + const state: GroupState = { + activeGroupIdByWorktree: { 'wt-1': 'focused-group' }, + groupsByWorktree: { + 'wt-1': [ + { id: 'first-group', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] }, + { id: 'focused-group', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] } + ] + } + } + + expect( + resolveCmdJActiveGroupId(state, 'wt-1', { + worktreeId: 'wt-1', + groupId: 'focused-group' + }) + ).toBe('focused-group') + expect( + resolveCmdJActiveGroupId(state, 'wt-1', { + worktreeId: 'wt-1', + groupId: 'closed-group' + }) + ).toBe('first-group') + expect(resolveCmdJActiveGroupId(state, 'wt-1', null)).toBe('focused-group') + }) + + it('applies workspace-scoped action availability gates in order', () => { + expect(getWorkspaceScopedActionAvailability(ctx({ activeWorktreeId: null }))).toEqual({ + available: false, + reason: 'no-active-workspace' + }) + expect(getWorkspaceScopedActionAvailability(ctx({ isLoading: true }))).toEqual({ + available: false, + reason: 'loading' + }) + expect(getWorkspaceScopedActionAvailability(ctx({ sshStatus: 'disconnected' }))).toEqual({ + available: false, + reason: 'ssh-disconnected' + }) + expect(getWorkspaceScopedActionAvailability(ctx({ activeGroupId: null }))).toEqual({ + available: false, + reason: 'no-active-group' + }) + expect(getWorkspaceScopedActionAvailability(ctx({}))).toEqual({ available: true }) + }) + + it('keeps workspace-agnostic actions available while loading without an active workspace', () => { + const context = { + ...ctx({ activeWorktreeId: null, activeGroupId: null, isLoading: true }), + activeWorktree: null, + runtimeMode: 'local-desktop' as const, + openNewBrowserTab: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + openAddQuickCommand: () => {} + } satisfies CmdJQuickActionContext + + expect( + CMD_J_QUICK_ACTIONS.find((action) => action.id === 'new-terminal-tab')?.isAvailable(context) + ).toEqual({ available: false, reason: 'no-active-workspace' }) + expect( + CMD_J_QUICK_ACTIONS.find((action) => action.id === 'create-workspace')?.isAvailable(context) + ).toEqual({ available: true }) + expect( + CMD_J_QUICK_ACTIONS.find((action) => action.id === 'add-quick-command')?.isAvailable(context) + ).toEqual({ available: true }) + }) + + it('applies the availability matrix across curated actions', () => { + const workspaceActions = ['new-browser-tab', 'new-markdown-file', 'new-terminal-tab'] + const workspaceAgnosticActions = ['create-workspace', 'add-quick-command'] + const actionById = new Map(CMD_J_QUICK_ACTIONS.map((action) => [action.id, action])) + const baseContext = { + ...ctx({}), + activeWorktree: null, + runtimeMode: 'local-desktop' as const, + openNewBrowserTab: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + openAddQuickCommand: () => {} + } satisfies CmdJQuickActionContext + + for (const actionId of workspaceActions) { + expect(actionById.get(actionId)?.isAvailable(baseContext)).toEqual({ available: true }) + expect( + actionById.get(actionId)?.isAvailable({ ...baseContext, runtimeMode: 'paired-web' }) + ).toEqual({ available: true }) + expect( + actionById.get(actionId)?.isAvailable({ + ...baseContext, + activeWorktreeId: null, + activeGroupId: null + }) + ).toEqual({ available: false, reason: 'no-active-workspace' }) + expect(actionById.get(actionId)?.isAvailable({ ...baseContext, isLoading: true })).toEqual({ + available: false, + reason: 'loading' + }) + expect( + actionById.get(actionId)?.isAvailable({ ...baseContext, sshStatus: 'disconnected' }) + ).toEqual({ available: false, reason: 'ssh-disconnected' }) + } + + for (const actionId of workspaceAgnosticActions) { + expect( + actionById.get(actionId)?.isAvailable({ + ...baseContext, + activeWorktreeId: null, + activeGroupId: null, + isLoading: true, + sshStatus: 'disconnected' + }) + ).toEqual({ available: true }) + } + }) + + it('recomputes active group from the open snapshot against fresh store state', () => { + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + displayName: 'Workspace', + branch: 'main', + createdAt: 0 + } as Worktree + const state = { + activeWorktreeId: 'wt-1', + worktreesByRepo: { 'repo-1': [worktree] }, + repos: [{ id: 'repo-1', path: '/repo', displayName: 'Repo', addedAt: 0 }], + sshConnectionStates: new Map(), + activeGroupIdByWorktree: { 'wt-1': 'closed-group' }, + groupsByWorktree: { + 'wt-1': [{ id: 'first-group', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] }] + }, + settings: null + } as unknown as AppState + + const context = buildCmdJQuickActionContext({ + state, + activeGroupSnapshot: { worktreeId: 'wt-1', groupId: 'closed-group' }, + openNewBrowserTab: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + openAddQuickCommand: () => {} + }) + + expect(context.activeGroupId).toBe('first-group') + }) + + it('derives loading from fresh store state when building the run-time context', () => { + const state = { + activeWorktreeId: null, + worktreesByRepo: {}, + repos: [{ id: 'repo-1', path: '/repo', displayName: 'Repo', addedAt: 0 }], + sshConnectionStates: new Map(), + activeGroupIdByWorktree: {}, + groupsByWorktree: {}, + settings: null + } as unknown as AppState + + const context = buildCmdJQuickActionContext({ + state, + activeGroupSnapshot: null, + openNewBrowserTab: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + openAddQuickCommand: () => {} + }) + + expect(context.isLoading).toBe(true) + }) + + it('runtime re-check returns unavailable without invoking the action helper', async () => { + const calls: string[] = [] + const action = CMD_J_QUICK_ACTIONS.find((entry) => entry.id === 'new-terminal-tab') + const context = { + ...ctx({ activeGroupId: null }), + activeWorktree: null, + runtimeMode: 'local-desktop' as const, + openNewBrowserTab: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async (groupId: string) => { + calls.push(groupId) + }, + openCreateWorkspace: () => {}, + openAddQuickCommand: () => {} + } satisfies CmdJQuickActionContext + + await expect(action?.run(context)).resolves.toEqual({ + status: 'unavailable', + reason: 'no-active-group' + }) + expect(calls).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/cmd-j/quick-action-context.ts b/src/renderer/src/components/cmd-j/quick-action-context.ts new file mode 100644 index 000000000..472bdb4a4 --- /dev/null +++ b/src/renderer/src/components/cmd-j/quick-action-context.ts @@ -0,0 +1,166 @@ +import type { AppState } from '@/store/types' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import type { Worktree } from '../../../../shared/types' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +export type CmdJUnavailableReason = + | 'loading' + | 'no-active-workspace' + | 'ssh-disconnected' + | 'no-active-group' + +export type CmdJQuickActionAvailability = + | { available: true } + | { available: false; reason: CmdJUnavailableReason } + +export type CmdJActiveGroupSnapshot = { + worktreeId: string + groupId: string | null +} + +export type CmdJQuickActionContext = { + activeWorktreeId: string | null + activeWorktree: Worktree | null + isLoading: boolean + sshStatus: SshConnectionStatus | null + runtimeMode: 'local-desktop' | 'paired-web' + activeGroupId: string | null + openNewBrowserTab: (groupId: string) => Promise + openNewMarkdownFile: (groupId: string) => Promise + openNewTerminalTab: (groupId: string) => Promise + openCreateWorkspace: () => void + openAddQuickCommand: () => void +} + +export function resolveCmdJActiveGroupId( + state: Pick, + worktreeId: string | null, + snapshot?: CmdJActiveGroupSnapshot | null +): string | null { + if (!worktreeId) { + return null + } + const groups = state.groupsByWorktree[worktreeId] ?? [] + if (groups.length === 0) { + return null + } + + if (snapshot?.worktreeId === worktreeId) { + if (snapshot.groupId && groups.some((group) => group.id === snapshot.groupId)) { + return snapshot.groupId + } + return groups[0]?.id ?? null + } + + const focusedGroupId = state.activeGroupIdByWorktree[worktreeId] + if (focusedGroupId && groups.some((group) => group.id === focusedGroupId)) { + return focusedGroupId + } + return groups[0]?.id ?? null +} + +export function captureCmdJActiveGroupSnapshot( + state: Pick, + worktreeId: string | null +): CmdJActiveGroupSnapshot | null { + if (!worktreeId) { + return null + } + return { + worktreeId, + groupId: resolveCmdJActiveGroupId(state, worktreeId) + } +} + +export function getActiveWorktreeSshStatus( + state: Pick, + activeWorktree: Worktree | null +): SshConnectionStatus | null { + if (!activeWorktree) { + return null + } + const repo = state.repos.find((entry) => entry.id === activeWorktree.repoId) + const connectionId = repo?.connectionId ?? null + if (!connectionId) { + return null + } + return state.sshConnectionStates.get(connectionId)?.status ?? 'disconnected' +} + +export function getWorkspaceScopedActionAvailability( + ctx: Pick< + CmdJQuickActionContext, + 'activeGroupId' | 'activeWorktreeId' | 'isLoading' | 'sshStatus' + > +): CmdJQuickActionAvailability { + if (!ctx.activeWorktreeId) { + return { available: false, reason: 'no-active-workspace' } + } + if (ctx.isLoading) { + return { available: false, reason: 'loading' } + } + if (ctx.sshStatus != null && ctx.sshStatus !== 'connected') { + return { available: false, reason: 'ssh-disconnected' } + } + if (!ctx.activeGroupId) { + return { available: false, reason: 'no-active-group' } + } + return { available: true } +} + +export function buildCmdJQuickActionContext(args: { + state: AppState + activeGroupSnapshot: CmdJActiveGroupSnapshot | null + openNewBrowserTab: (groupId: string) => Promise + openNewMarkdownFile: (groupId: string) => Promise + openNewTerminalTab: (groupId: string) => Promise + openCreateWorkspace: () => void + openAddQuickCommand: () => void +}): CmdJQuickActionContext { + const activeWorktreeId = args.state.activeWorktreeId + const activeWorktree = activeWorktreeId + ? (findWorktreeById(args.state.worktreesByRepo, activeWorktreeId) ?? null) + : null + const activeGroupId = resolveCmdJActiveGroupId( + args.state, + activeWorktreeId, + args.activeGroupSnapshot + ) + const isLoading = + args.state.repos.length > 0 && Object.keys(args.state.worktreesByRepo).length === 0 + const runtimeMode = + (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ && + args.state.settings?.activeRuntimeEnvironmentId?.trim() + ? 'paired-web' + : 'local-desktop' + + return { + activeWorktreeId, + activeWorktree, + isLoading, + sshStatus: getActiveWorktreeSshStatus(args.state, activeWorktree), + runtimeMode, + activeGroupId, + openNewBrowserTab: args.openNewBrowserTab, + openNewMarkdownFile: args.openNewMarkdownFile, + openNewTerminalTab: args.openNewTerminalTab, + openCreateWorkspace: args.openCreateWorkspace, + openAddQuickCommand: args.openAddQuickCommand + } +} + +export function getUnavailableQuickActionMessage( + actionTitle: string, + reason: CmdJUnavailableReason +): string { + switch (reason) { + case 'loading': + return `Can't ${actionTitle.toLowerCase()} — workspace is still loading.` + case 'no-active-workspace': + return `Can't ${actionTitle.toLowerCase()} — no workspace is active.` + case 'ssh-disconnected': + return `Can't ${actionTitle.toLowerCase()} — workspace is disconnected.` + case 'no-active-group': + return `Can't ${actionTitle.toLowerCase()} — no tab group is available.` + } +} diff --git a/src/renderer/src/components/cmd-j/quick-actions.ts b/src/renderer/src/components/cmd-j/quick-actions.ts new file mode 100644 index 000000000..2d6ca8c88 --- /dev/null +++ b/src/renderer/src/components/cmd-j/quick-actions.ts @@ -0,0 +1,103 @@ +import { FileText, FolderPlus, Globe, Play, SquareTerminal } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import type { CmdJQuickActionAvailability, CmdJQuickActionContext } from './quick-action-context' +import { getWorkspaceScopedActionAvailability } from './quick-action-context' + +export type CmdJQuickActionRunResult = + | { status: 'ok' } + | { + status: 'unavailable' + reason: Exclude['reason'] + } + +export type CmdJQuickAction = { + id: string + kind: 'action' + title: string + description: string + icon: LucideIcon + verbKeywords: string[] + isAvailable: (ctx: CmdJQuickActionContext) => CmdJQuickActionAvailability + run: (ctx: CmdJQuickActionContext) => Promise +} + +function workspaceActionAvailability(ctx: CmdJQuickActionContext): CmdJQuickActionAvailability { + return getWorkspaceScopedActionAvailability(ctx) +} + +async function runWorkspaceAction( + ctx: CmdJQuickActionContext, + run: (groupId: string) => Promise +): Promise { + const availability = workspaceActionAvailability(ctx) + if (!availability.available) { + return { status: 'unavailable', reason: availability.reason } + } + if (!ctx.activeGroupId) { + return { status: 'unavailable', reason: 'no-active-group' } + } + await run(ctx.activeGroupId) + return { status: 'ok' } +} + +// Why: Cmd+J actions are for high-frequency, safe, context-light verbs. +// Context-heavy setup flows such as Ghostty import and browser cookie import +// stay inside their Settings panes where explanatory UI and failure states fit. +export const CMD_J_QUICK_ACTIONS: readonly CmdJQuickAction[] = [ + { + id: 'new-browser-tab', + kind: 'action', + title: 'New Browser Tab', + description: 'Open a browser tab in the active workspace.', + icon: Globe, + verbKeywords: ['new browser', 'new browser tab', 'open browser', 'browser tab'], + isAvailable: workspaceActionAvailability, + run: (ctx) => runWorkspaceAction(ctx, ctx.openNewBrowserTab) + }, + { + id: 'new-markdown-file', + kind: 'action', + title: 'New Markdown File', + description: 'Create an untitled markdown file in the active workspace.', + icon: FileText, + verbKeywords: ['new markdown', 'new markdown file', 'new mark', 'new file', 'markdown file'], + isAvailable: workspaceActionAvailability, + run: (ctx) => runWorkspaceAction(ctx, ctx.openNewMarkdownFile) + }, + { + id: 'new-terminal-tab', + kind: 'action', + title: 'New Terminal Tab', + description: 'Open a terminal tab in the active workspace.', + icon: SquareTerminal, + verbKeywords: ['new terminal', 'new terminal tab', 'new shell', 'terminal tab'], + isAvailable: workspaceActionAvailability, + run: (ctx) => runWorkspaceAction(ctx, ctx.openNewTerminalTab) + }, + { + id: 'create-workspace', + kind: 'action', + title: 'Create Workspace', + description: 'Start a new workspace.', + icon: FolderPlus, + verbKeywords: ['create workspace', 'add workspace', 'new workspace'], + isAvailable: () => ({ available: true }), + run: async (ctx) => { + ctx.openCreateWorkspace() + return { status: 'ok' } + } + }, + { + id: 'add-quick-command', + kind: 'action', + title: 'Add Quick Command', + description: 'Create a saved terminal command.', + icon: Play, + verbKeywords: ['add quick command', 'new quick command'], + isAvailable: () => ({ available: true }), + run: async (ctx) => { + ctx.openAddQuickCommand() + return { status: 'ok' } + } + } +] diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index b821b2189..53c56b24e 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -1,9 +1,9 @@ import type React from 'react' -import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/types' import { Separator } from '../ui/separator' import { UIZoomControl } from './UIZoomControl' import { SearchableSetting } from './SearchableSetting' -import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '../ShortcutKeyCombo' @@ -16,6 +16,18 @@ import { } from './SettingsFormControls' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles' +import { + APPEARANCE_PANE_SEARCH_ENTRIES, + LAYOUT_ENTRIES, + SIDEBAR_ENTRIES, + STATUS_BAR_ENTRIES, + STATUS_BAR_TOGGLES, + THEME_ENTRIES, + TITLEBAR_ENTRIES, + TYPOGRAPHY_ENTRIES, + ZOOM_ENTRIES +} from './appearance-search' +export { APPEARANCE_PANE_SEARCH_ENTRIES } type AppearancePaneProps = { settings: GlobalSettings @@ -24,139 +36,6 @@ type AppearancePaneProps = { fontSuggestions: string[] } -const STATUS_BAR_TOGGLES: readonly { - id: StatusBarItem - title: string - description: string - keywords: string[] - toggleDescription: string -}[] = [ - { - id: 'claude', - title: 'Claude Usage', - description: 'Show Claude token and cost usage in the status bar.', - keywords: ['status bar', 'claude', 'usage', 'tokens', 'cost', 'anthropic'], - toggleDescription: 'Show Claude token and cost usage for the active workspace.' - }, - { - id: 'codex', - title: 'Codex Usage', - description: 'Show Codex token and cost usage in the status bar.', - keywords: ['status bar', 'codex', 'usage', 'tokens', 'cost', 'openai'], - toggleDescription: 'Show Codex token and cost usage for the active workspace.' - }, - { - id: 'gemini', - title: 'Gemini Usage', - description: 'Show Gemini token and cost usage in the status bar.', - keywords: ['status bar', 'gemini', 'usage', 'tokens', 'cost', 'google'], - toggleDescription: 'Show Gemini token and cost usage for the active workspace.' - }, - { - id: 'opencode-go', - title: 'OpenCode Go Usage', - description: 'Show OpenCode Go token and cost usage in the status bar.', - keywords: ['status bar', 'opencode', 'opencode-go', 'usage', 'tokens', 'cost'], - toggleDescription: 'Show OpenCode Go token and cost usage for the active workspace.' - }, - { - id: 'ssh', - title: 'SSH Status', - description: 'Show the active SSH connection status in the status bar.', - keywords: ['status bar', 'ssh', 'remote', 'connection', 'host'], - toggleDescription: - 'Show the active SSH connection. Only visible once an SSH target is configured.' - }, - { - id: 'resource-usage', - title: 'Resource Manager', - description: 'Show CPU, memory, terminal sessions, and workspace disk usage in the status bar.', - keywords: ['status bar', 'resource', 'manager', 'memory', 'cpu', 'terminal', 'disk', 'space'], - toggleDescription: - 'Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.' - }, - { - id: 'ports', - title: 'Ports', - description: 'Show live workspace ports in the status bar.', - keywords: ['status bar', 'ports', 'localhost', 'server', 'workspace'], - toggleDescription: - 'Show live workspace ports. Click it for workspace-scoped ports and external listeners.' - } -] - -const THEME_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Theme', - description: 'Choose how Orca looks in the app window.', - keywords: ['dark', 'light', 'system'] - } -] - -const ZOOM_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'UI Zoom', - description: 'Scale the entire application interface.', - keywords: ['zoom', 'scale', 'shortcut'] - } -] - -const TYPOGRAPHY_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'IDE Font', - description: 'Choose the font used by the Orca interface.', - keywords: ['font', 'typeface', 'typography', 'ide', 'orca', 'interface', 'app', 'ui'] - } -] - -const LAYOUT_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Open Right Sidebar by Default', - description: 'Automatically expand the file explorer panel when creating a new worktree.', - keywords: ['layout', 'file explorer', 'sidebar'] - }, - { - title: 'Show Git-Ignored Files', - description: 'Dim files matched by .gitignore in the file explorer.', - keywords: ['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide'] - } -] - -const TITLEBAR_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Titlebar App Name', - description: 'Show Orca in the titlebar.', - keywords: ['titlebar', 'orca', 'app', 'name', 'brand'] - } -] - -const STATUS_BAR_ENTRIES: SettingsSearchEntry[] = STATUS_BAR_TOGGLES.map( - ({ title, description, keywords }) => ({ title, description, keywords }) -) - -const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Show Tasks Button', - description: 'Show the Tasks button at the top of the left sidebar.', - keywords: ['tasks', 'sidebar', 'button', 'hide', 'show', 'github', 'linear'] - }, - { - title: 'Show Orca Mobile Button', - description: 'Show the Orca Mobile button at the top of the left sidebar.', - keywords: ['mobile', 'phone', 'sidebar', 'button', 'hide', 'show', 'toolbox'] - } -] - -export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - ...THEME_ENTRIES, - ...TYPOGRAPHY_ENTRIES, - ...ZOOM_ENTRIES, - ...LAYOUT_ENTRIES, - ...TITLEBAR_ENTRIES, - ...STATUS_BAR_ENTRIES, - ...SIDEBAR_ENTRIES -] - function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element { if (combos.length === 0) { return Unassigned diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index e863655ba..8f5da6e51 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -18,14 +18,11 @@ import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { BROWSER_PANE_SEARCH_ENTRIES as BROWSER_CORE_SEARCH_ENTRIES } from './browser-search' import { BROWSER_USE_PANE_SEARCH_ENTRIES } from './browser-use-search' +import { BROWSER_PANE_SEARCH_ENTRIES } from './browser-pane-search' import { BrowserProfileRow } from './BrowserProfileRow' import { BrowserUseSetup } from './BrowserUsePane' import { KagiSessionLinkForm } from './KagiSessionLinkForm' - -export const BROWSER_PANE_SEARCH_ENTRIES = [ - ...BROWSER_USE_PANE_SEARCH_ENTRIES, - ...BROWSER_CORE_SEARCH_ENTRIES -] +export { BROWSER_PANE_SEARCH_ENTRIES } type BrowserPaneProps = { settings: GlobalSettings diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index c08591905..6f20eb834 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -27,22 +27,7 @@ import { } from '@/hooks/useInstalledAgentSkills' import { Button } from '../ui/button' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import type { SettingsSearchEntry } from './settings-search' - -export const COMPUTER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Computer Use', - description: 'Allow agents to inspect screenshots and operate local apps when you ask.', - keywords: [ - 'computer use', - 'accessibility', - 'screen recording', - 'screenshot', - 'automation', - 'skill' - ] - } -] +export { COMPUTER_USE_PANE_SEARCH_ENTRIES } from './computer-use-search' type PermissionDefinition = { id: ComputerUsePermissionId diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 1273fda68..7e0b085ef 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -20,35 +20,7 @@ import type { DeveloperPermissionStatus } from '../../../../shared/developer-permissions-types' import { Button } from '../ui/button' -import type { SettingsSearchEntry } from './settings-search' - -export const DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Developer Permissions', - description: 'macOS permissions for terminal-launched developer tools.', - keywords: ['permissions', 'privacy', 'tcc', 'macos', 'developer tools'] - }, - { - title: 'Microphone and Camera', - description: 'Allow voice, transcription, webcam, and media capture tools.', - keywords: ['microphone', 'camera', 'voice', 'audio', 'video', 'sox', 'ffmpeg', 'whisper'] - }, - { - title: 'Screen Recording and Accessibility', - description: 'Allow screenshots, screen inspection, keystrokes, and window automation.', - keywords: ['screen recording', 'accessibility', 'screenshot', 'automation', 'window'] - }, - { - title: 'Full Disk Access', - description: 'Open the macOS privacy pane for broad terminal file access.', - keywords: ['full disk access', 'documents', 'downloads', 'desktop', 'icloud'] - }, - { - title: 'Local Network, USB, and Bluetooth', - description: 'Allow device and local-network tools used from terminal sessions.', - keywords: ['local network', 'usb', 'bluetooth', 'bonjour', 'mdns', 'device'] - } -] +export { DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES } from './developer-permissions-search' type PermissionDefinition = { id: DeveloperPermissionId diff --git a/src/renderer/src/components/settings/InputPane.tsx b/src/renderer/src/components/settings/InputPane.tsx index cac20c9d5..d022c174d 100644 --- a/src/renderer/src/components/settings/InputPane.tsx +++ b/src/renderer/src/components/settings/InputPane.tsx @@ -1,29 +1,8 @@ import type { GlobalSettings } from '../../../../shared/types' import { Label } from '../ui/label' import { SearchableSetting } from './SearchableSetting' -import type { SettingsSearchEntry } from './settings-search' import { isDefaultPrimarySelectionMiddleClickPasteUserAgent } from '@/hooks/usePrimarySelectionPaste' - -export const INPUT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Middle-click Paste from Selection', - description: - 'Enabled by default on Linux and macOS. Linux uses the system selection clipboard; other platforms use a private buffer.', - keywords: [ - 'input', - 'editing', - 'selection', - 'primary selection', - 'middle click', - 'middle mouse', - 'paste', - 'clipboard', - 'x11', - 'linux', - 'macos' - ] - } -] +export { INPUT_PANE_SEARCH_ENTRIES } from './input-search' type InputPaneProps = { settings: GlobalSettings diff --git a/src/renderer/src/components/settings/IntegrationsPane.tsx b/src/renderer/src/components/settings/IntegrationsPane.tsx index b11a0f14e..b1f456392 100644 --- a/src/renderer/src/components/settings/IntegrationsPane.tsx +++ b/src/renderer/src/components/settings/IntegrationsPane.tsx @@ -27,7 +27,7 @@ import { DialogHeader, DialogTitle } from '../ui/dialog' -import type { SettingsSearchEntry } from './settings-search' +export { INTEGRATIONS_PANE_SEARCH_ENTRIES } from './integrations-search' function LinearIcon({ className }: { className?: string }): React.JSX.Element { return ( @@ -37,39 +37,6 @@ function LinearIcon({ className }: { className?: string }): React.JSX.Element { ) } -export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'GitHub Integration', - description: 'GitHub authentication via the gh CLI.', - keywords: ['github', 'gh', 'integration'] - }, - { - title: 'GitLab Integration', - description: 'GitLab authentication via the glab CLI.', - keywords: ['gitlab', 'glab', 'integration', 'mr', 'merge request'] - }, - { - title: 'Bitbucket Integration', - description: 'Bitbucket Cloud authentication via API token environment variables.', - keywords: ['bitbucket', 'integration', 'pull request', 'api token'] - }, - { - title: 'Azure DevOps Integration', - description: 'Azure DevOps Repos authentication via token environment variables.', - keywords: ['azure devops', 'azure repos', 'ado', 'integration', 'pull request', 'api token'] - }, - { - title: 'Gitea Integration', - description: 'Gitea authentication via API token environment variables.', - keywords: ['gitea', 'self-hosted', 'integration', 'pull request', 'api token'] - }, - { - title: 'Linear Integration', - description: 'Connect Linear to browse and link issues.', - keywords: ['linear', 'integration', 'api key', 'connect', 'disconnect'] - } -] - type GhStatus = 'checking' | 'connected' | 'not-installed' | 'not-authenticated' // Why: parallel to GhStatus — GitLab uses glab and the same three failure // modes (probe in-flight / installed-but-unauth / missing entirely). diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 97a438602..4972911ac 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -4,7 +4,6 @@ import { Check, Copy, Maximize2, Smartphone, Trash2 } from 'lucide-react' import { Button } from '../ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import type { SettingsSearchEntry } from './settings-search' import { useAppStore } from '../../store' import { useMobilePairingDevicePolling } from './mobile-pairing-device-polling' import { @@ -12,6 +11,7 @@ import { type MobileNetworkInterface } from './mobile-network-interface-selection' import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' +export { MOBILE_PANE_SEARCH_ENTRIES } from './mobile-pane-search' // Why: the section heading "When you leave the mobile app" carries the // "what happens" framing so the option labels only need to vary on the @@ -32,54 +32,6 @@ function autoRestoreValueFromMs(ms: number | null | undefined): string { return exact ? exact.value : 'indefinite' } -export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Mobile Pairing', - description: 'Pair a mobile device by scanning a QR code.', - keywords: ['mobile', 'qr', 'code', 'pair', 'phone', 'scan'] - }, - { - title: 'Connected Devices', - description: 'Manage paired mobile devices.', - keywords: ['mobile', 'devices', 'revoke', 'paired', 'connected'] - }, - { - title: 'Network Interface', - description: 'Choose which network address to use for mobile pairing.', - keywords: [ - 'network', - 'interface', - 'tailscale', - 'tailnet', - 'vpn', - 'overlay', - 'ip', - 'address', - 'wifi', - 'lan', - 'remote' - ] - }, - { - title: 'When you leave the mobile app', - description: - 'Choose what happens to terminals you were viewing on mobile after you close the app or switch away.', - keywords: [ - 'mobile', - 'terminal', - 'restore', - 'phone', - 'fit', - 'width', - 'resize', - 'hold', - 'leave', - 'background', - 'close' - ] - } -] - type PairedDevice = { deviceId: string name: string diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 22c360a64..ad3e5bffc 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -1,36 +1,18 @@ import type { GlobalSettings } from '../../../../shared/types' import { Label } from '../ui/label' import { SearchableSetting } from './SearchableSetting' -import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' -import { MobilePane, MOBILE_PANE_SEARCH_ENTRIES } from './MobilePane' +import { MobilePane } from './MobilePane' +import { + MOBILE_ENABLE_SEARCH_ENTRY, + MOBILE_SETTINGS_PANE_SEARCH_ENTRIES +} from './mobile-settings-search' +export { MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' const ORCA_ANDROID_RELEASE_URL = 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9' -const MOBILE_ENABLE_SEARCH_ENTRY: SettingsSearchEntry = { - title: 'Mobile', - description: 'Control terminals and agents from your phone.', - keywords: [ - 'mobile', - 'phone', - 'pair', - 'qr', - 'code', - 'scan', - 'remote', - 'android', - 'apk', - 'beta', - 'experimental' - ] -} - -export const MOBILE_SETTINGS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - MOBILE_ENABLE_SEARCH_ENTRY, - ...MOBILE_PANE_SEARCH_ENTRIES -] - type MobileSettingsPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void diff --git a/src/renderer/src/components/settings/NotificationsPane.tsx b/src/renderer/src/components/settings/NotificationsPane.tsx index fed96ee78..a72347ab8 100644 --- a/src/renderer/src/components/settings/NotificationsPane.tsx +++ b/src/renderer/src/components/settings/NotificationsPane.tsx @@ -15,59 +15,8 @@ import { SelectValue } from '../ui/select' import { BellRing, Bot, FileAudio, Siren, Upload, Volume2 } from 'lucide-react' -import type { SettingsSearchEntry } from './settings-search' import { getNotificationSoundOptions } from '@/components/notification-sound-options' - -export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ - { - title: 'Enable Notifications', - description: 'Master switch for Orca desktop notifications.', - keywords: ['notifications', 'desktop', 'system', 'native'] - }, - { - title: 'Agent Task Complete', - description: 'Notify when a coding agent transitions from working to idle.', - keywords: ['notifications', 'agent', 'complete', 'idle', 'task'] - }, - { - title: 'Terminal Bell', - description: 'Notify when a background terminal emits a bell character.', - keywords: ['notifications', 'terminal', 'bell', 'attention'] - }, - { - title: 'Suppress While Focused', - description: 'Avoid notifying when Orca is focused on the active worktree.', - keywords: ['notifications', 'focused', 'suppress', 'filtering'] - }, - { - title: 'Notification Sound', - description: - 'Choose the built-in, system, or local audio file Orca plays for desktop notifications.', - keywords: [ - 'notifications', - 'sound', - 'audio', - 'mp3', - 'wav', - 'ogg', - 'm4a', - 'aac', - 'flac', - 'ding', - 'bong' - ] - }, - { - title: 'Notification Volume', - description: 'Playback volume for non-system notification sounds.', - keywords: ['notifications', 'sound', 'volume', 'loudness'] - }, - { - title: 'Send Test Notification', - description: 'Trigger a sample desktop notification using the native delivery path.', - keywords: ['notifications', 'test'] - } -] +export { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './notifications-search' type NotificationsPaneProps = { settings: GlobalSettings diff --git a/src/renderer/src/components/settings/QuickCommandsPane.test.ts b/src/renderer/src/components/settings/QuickCommandsPane.test.ts new file mode 100644 index 000000000..ac0689d92 --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsPane.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { shouldOpenQuickCommandAddIntent } from './QuickCommandsPane' + +describe('QuickCommandsPane add-command intent', () => { + it('opens the add flow once for each new intent signal', () => { + expect(shouldOpenQuickCommandAddIntent(undefined, 0)).toBe(false) + expect(shouldOpenQuickCommandAddIntent(0, 0)).toBe(false) + expect(shouldOpenQuickCommandAddIntent(1, 0)).toBe(true) + expect(shouldOpenQuickCommandAddIntent(1, 1)).toBe(false) + expect(shouldOpenQuickCommandAddIntent(2, 1)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index ac4f03d5a..ab669d931 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronsUpDown, Pencil, Plus, Trash2 } from 'lucide-react' import type { GlobalSettings, @@ -24,6 +24,7 @@ import { useConfirmationDialog } from '@/components/confirmation-dialog' type QuickCommandsPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + addCommandIntentSignal?: number } const GLOBAL_SCOPE_KEY = '__global__' @@ -43,6 +44,13 @@ function getRepoLabel(repo: Pick): string { return repo.displayName || repo.path } +export function shouldOpenQuickCommandAddIntent( + addCommandIntentSignal: number | undefined, + consumedAddIntentSignal: number +): boolean { + return Boolean(addCommandIntentSignal && consumedAddIntentSignal !== addCommandIntentSignal) +} + function getScopeLabel( scope: TerminalQuickCommandScope, repoById: Map> @@ -56,7 +64,8 @@ function getScopeLabel( export function QuickCommandsPane({ settings, - updateSettings + updateSettings, + addCommandIntentSignal }: QuickCommandsPaneProps): React.JSX.Element { const repos = useAppStore((s) => s.repos) const activeRepoId = useAppStore((s) => s.activeRepoId) @@ -64,6 +73,7 @@ export function QuickCommandsPane({ const confirm = useConfirmationDialog() const [editor, setEditor] = useState(null) + const consumedAddIntentSignalRef = useRef(0) // Why: `null` means "show all" (sticky-all), independent of the current repo // list — mirrors the tasks-page repo combobox so newly added repos appear // automatically rather than being silently excluded. @@ -90,7 +100,7 @@ export function QuickCommandsPane({ return effectiveSelection.has(scope.repoId) }) - const createDraftForCurrentFilter = (): TerminalQuickCommand => { + const createDraftForCurrentFilter = useCallback((): TerminalQuickCommand => { // Why: when the user has narrowed to a single repo scope, the natural // intent for "Add Command" is to create one in that repo. When the filter // is narrowed to Global-only, honor that. Otherwise prefer the active @@ -108,7 +118,19 @@ export function QuickCommandsPane({ return createTerminalQuickCommandDraft({ type: 'repo', repoId: activeRepoId }) } return createTerminalQuickCommandDraft({ type: 'global' }) - } + }, [activeRepoId, effectiveSelection, repoById, showAll]) + + useEffect(() => { + const intentSignal = addCommandIntentSignal + if ( + typeof intentSignal !== 'number' || + !shouldOpenQuickCommandAddIntent(intentSignal, consumedAddIntentSignalRef.current) + ) { + return + } + consumedAddIntentSignalRef.current = intentSignal + setEditor({ mode: 'add', command: createDraftForCurrentFilter() }) + }, [addCommandIntentSignal, createDraftForCurrentFilter]) const toggleScope = (key: string): void => { const current = new Set(effectiveSelection) diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 1a4b8ff5d..eebc82dfb 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -12,10 +12,12 @@ import { McpConfigSection } from './McpConfigSection' import { WorktreeSymlinksSection } from './WorktreeSymlinksSection' import { SparsePresetSettingsSection } from './SparsePresetSettingsSection' import { SearchableSetting } from './SearchableSetting' -import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' import { getRepositoryIconSectionId } from './repository-settings-targets' import { RepositoryIconPicker } from './RepositoryIconPicker' +import { getRepositoryPaneSearchEntries } from './repository-search' +export { getRepositoryPaneSearchEntries } type RepositoryPaneProps = { repo: Repo @@ -27,157 +29,6 @@ type RepositoryPaneProps = { removeRepo: (repoId: string) => void } -export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[] { - const isFolder = isFolderRepo(repo) - return [ - { - title: 'Display Name', - description: 'Project-specific display details for the sidebar and tabs.', - keywords: [repo.displayName, repo.path, 'project name', 'repository name'] - }, - { - title: 'Project Icon', - description: 'Project icon and color used in the sidebar and tabs.', - keywords: [ - repo.displayName, - 'project icon', - 'repository icon', - 'color', - 'badge', - 'emoji', - 'favicon' - ] - }, - ...(isFolder - ? [] - : [ - { - title: 'Default Worktree Base', - description: 'Default base branch or ref when creating worktrees.', - keywords: [repo.displayName, 'base ref', 'branch'] - }, - { - title: 'Sparse Checkout Presets', - description: 'Saved directory sets for sparse worktree creation.', - keywords: [ - repo.displayName, - 'sparse', - 'checkout', - 'preset', - 'presets', - 'directory', - 'directories', - 'monorepo' - ] - } - ]), - { - title: 'Remove Project', - description: 'Remove this project from Orca.', - keywords: [repo.displayName, 'delete', 'project', 'repository'] - }, - ...(isFolder - ? [] - : [ - { - title: 'Worktree Symlinks', - description: 'Paths to symlink from the primary checkout into newly created worktrees.', - keywords: [ - repo.displayName, - 'symlink', - 'symlinks', - 'worktree', - 'link', - 'shared', - 'env', - 'node_modules' - ] - }, - { - title: 'MCP Configs', - description: 'Inspect project-level MCP server config files.', - keywords: [ - repo.displayName, - 'mcp', - 'model context protocol', - '.mcp.json', - '.cursor/mcp.json', - '.claude.json', - '.claude/mcp.json' - ] - }, - { - title: 'Setup Script', - description: 'Local and shared scripts that run after a new worktree is created.', - keywords: [ - repo.displayName, - 'hooks', - 'setup', - 'setup script', - 'setup command', - 'local settings scripts', - 'orca.yaml hooks', - 'yaml' - ] - }, - { - title: 'Archive Script', - description: 'Local and shared scripts that run before a worktree is archived.', - keywords: [ - repo.displayName, - 'hooks', - 'archive', - 'archive script', - 'archive command', - 'local settings scripts', - 'orca.yaml hooks', - 'yaml' - ] - }, - { - title: 'Advanced', - description: 'Command source and orca.yaml details.', - keywords: [ - repo.displayName, - 'advanced', - 'command source', - 'local', - 'orca.yaml', - 'shared', - 'both', - 'source', - 'authoritative' - ] - }, - { - title: 'When to Run Setup', - description: 'Choose the default behavior when a setup script is available.', - keywords: [ - repo.displayName, - 'setup run policy', - 'ask', - 'run by default', - 'skip by default' - ] - }, - { - title: 'Custom GitHub Issue Command', - description: - 'File-based linked-issue command configured via orca.yaml and optional local override.', - keywords: [ - repo.displayName, - 'github issue command', - 'issue command', - 'workflow', - 'github', - 'orca.yaml', - '.orca/issue-command' - ] - } - ]) - ] -} - export function RepositoryPane({ repo, yamlHooks, diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 76b06d4bc..3e76fcdda 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1,34 +1,8 @@ /* eslint-disable max-lines */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - BarChart3, - Bell, - Bot, - Cable, - FlaskConical, - GitBranch, - Globe, - Info, - Keyboard, - ListChecks, - Lock, - MousePointerClick, - Network, - PanelsTopLeft, - Play, - ShieldCheck, - Palette, - Server, - SlidersHorizontal, - Smartphone, - Blocks, - Mic, - SquareTerminal, - TextCursorInput, - UserCog -} from 'lucide-react' +import { Info } from 'lucide-react' import type { OrcaHooks } from '../../../../shared/types' -import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' +import { isFolderRepo } from '../../../../shared/repo-kind' import { useAppStore } from '../../store' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' @@ -36,58 +10,52 @@ import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' -import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane' -import { BrowserPane, BROWSER_PANE_SEARCH_ENTRIES } from './BrowserPane' -import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane' -import { InputPane, INPUT_PANE_SEARCH_ENTRIES } from './InputPane' -import { ShortcutsPane, SHORTCUTS_PANE_SEARCH_ENTRIES } from './ShortcutsPane' +import { GeneralPane } from './GeneralPane' +import { BrowserPane } from './BrowserPane' +import { AppearancePane } from './AppearancePane' +import { InputPane } from './InputPane' +import { ShortcutsPane } from './ShortcutsPane' import { TerminalPane } from './TerminalPane' import { FloatingWorkspacePane } from './FloatingWorkspacePane' import { useGhosttyImport } from './useGhosttyImport' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import ghosttyIcon from '../../../../../resources/ghostty.svg' -import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane' -import { getTerminalPaneSearchEntries } from './terminal-search' -import { FLOATING_WORKSPACE_SEARCH_ENTRIES } from './floating-workspace-search' -import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane' +import { RepositoryPane } from './RepositoryPane' +import { GitPane } from './GitPane' import { CommitMessageAiPane } from './CommitMessageAiPane' -import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search' -import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './NotificationsPane' +import { NotificationsPane } from './NotificationsPane' import { VoicePane } from './VoicePane' -import { VOICE_PANE_SEARCH_ENTRIES } from './voice-pane-search' -import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane' -import { ExperimentalPane, EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './ExperimentalPane' -import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane' +import { SshPane } from './SshPane' +import { ExperimentalPane } from './ExperimentalPane' +import { AgentsPane } from './AgentsPane' import { OrchestrationPane } from './OrchestrationPane' -import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from './orchestration-search' -import { AccountsPane, ACCOUNTS_PANE_SEARCH_ENTRIES } from './AccountsPane' -import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane' -import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane' +import { AccountsPane } from './AccountsPane' +import { StatsPane } from '../stats/StatsPane' +import { IntegrationsPane } from './IntegrationsPane' import { TasksPane } from './TasksPane' -import { TASKS_PANE_SEARCH_ENTRIES } from './tasks-search' import { QuickCommandsPane } from './QuickCommandsPane' -import { QUICK_COMMANDS_PANE_SEARCH_ENTRIES } from './quick-commands-search' -import { - DeveloperPermissionsPane, - DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES -} from './DeveloperPermissionsPane' -import { ComputerUsePane, COMPUTER_USE_PANE_SEARCH_ENTRIES } from './ComputerUsePane' -import { MobileSettingsPane, MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } from './MobileSettingsPane' +import { DeveloperPermissionsPane } from './DeveloperPermissionsPane' +import { ComputerUsePane } from './ComputerUsePane' +import { MobileSettingsPane } from './MobileSettingsPane' import { RuntimeEnvironmentsPane } from './RuntimeEnvironmentsPane' -import { - RUNTIME_ENVIRONMENTS_SEARCH_ENTRY, - WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY -} from './runtime-environments-search' import { PrivacyPane } from './PrivacyPane' -import { PRIVACY_PANE_SEARCH_ENTRIES } from './privacy-search' import { SettingsSidebar } from './SettingsSidebar' import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection' -import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch } from './settings-search' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { keybindingMatchesAction } from '../../../../shared/keybindings' +import { + isWebClientLocation, + useSettingsNavigationMetadata +} from '@/hooks/useSettingsNavigationMetadata' +import type { + SettingsNavGroup, + SettingsNavSection, + SettingsNavTarget +} from '@/lib/settings-navigation-types' import { deriveNeededRepoIds, deriveNeededSectionIds, @@ -95,50 +63,6 @@ import { getRuntimeTargetIdentity } from './settings-load-performance' -type SettingsNavTarget = - | 'general' - | 'integrations' - | 'accounts' - | 'browser' - | 'git' - | 'tasks' - | 'appearance' - | 'input' - | 'floating-workspace' - | 'terminal' - | 'quick-commands' - | 'notifications' - | 'computer-use' - | 'developer-permissions' - | 'privacy' - | 'voice' - | 'shortcuts' - | 'stats' - | 'ssh' - | 'privacy' - | 'experimental' - | 'agents' - | 'orchestration' - | 'servers' - | 'mobile' - | 'repo' - -type SettingsNavSection = { - id: string - title: string - description: string - icon: typeof SlidersHorizontal - searchEntries: SettingsSearchEntry[] - group: string - badge?: string -} - -type SettingsNavGroup = { - id: string - title: string - sections: SettingsNavSection[] -} - const SETTINGS_NAV_GROUPS = [ { id: 'setup', title: 'Set Up' }, { id: 'workflows', title: 'Workflows' }, @@ -211,13 +135,6 @@ function isEditableTarget(target: EventTarget | null): boolean { return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' } -function isWebClientLocation(): boolean { - return ( - Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) || - window.location.pathname.endsWith('/web-index.html') - ) -} - function Settings(): React.JSX.Element { const settings = useAppStore((s) => s.settings) const keybindings = useAppStore((s) => s.keybindings) @@ -248,10 +165,6 @@ function Settings(): React.JSX.Element { // Why: the Terminal settings section shares one search index with the // sidebar. We trim platform-only entries on other platforms so search never // reveals controls that the renderer will intentionally hide. - const terminalPaneSearchEntries = useMemo( - () => getTerminalPaneSearchEntries({ isWindows, isMac }), - [isWindows, isMac] - ) const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset') const [prevScrollbackBytes, setPrevScrollbackBytes] = useState(settings?.terminalScrollbackBytes) // Why: lifted out of TerminalPane so the Terminal section header can render @@ -266,6 +179,7 @@ function Settings(): React.JSX.Element { getInitialMountedSectionIds ) const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0) + const [quickCommandAddIntentSignal, setQuickCommandAddIntentSignal] = useState(0) const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false) const [commitPromptDiscardSignal, setCommitPromptDiscardSignal] = useState(0) const confirm = useConfirmationDialog() @@ -408,6 +322,9 @@ function Settings(): React.JSX.Element { ) pendingNavSectionRef.current = paneSectionId pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId + if (settingsNavigationTarget.intent === 'add-quick-command') { + setQuickCommandAddIntentSignal((signal) => signal + 1) + } setMountedSectionIds((previous) => { if (previous.has(paneSectionId)) { return previous @@ -439,243 +356,13 @@ function Settings(): React.JSX.Element { }, []) const displayedGitUsername = repos[0]?.gitUsername ?? '' - const runtimeEnvironmentsSearchEntry = isWebClient - ? WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY - : RUNTIME_ENVIRONMENTS_SEARCH_ENTRY - - const navSections = useMemo( - () => [ - { - id: 'general', - title: 'General', - description: 'Workspace defaults, app setup, and maintenance.', - icon: SlidersHorizontal, - searchEntries: GENERAL_PANE_SEARCH_ENTRIES, - group: 'setup' - }, - { - id: 'agents', - title: 'Agents', - description: 'Manage AI agents, set a default, and customize commands.', - icon: Bot, - searchEntries: AGENTS_PANE_SEARCH_ENTRIES, - group: 'setup' - }, - { - id: 'accounts', - title: 'AI Provider Accounts', - description: 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.', - icon: UserCog, - searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES, - group: 'setup', - badge: 'Optional' - }, - { - id: 'integrations', - title: 'Integrations', - description: 'Connect GitHub, GitLab, Linear, and source-hosting services.', - icon: Blocks, - searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES, - group: 'setup' - }, - { - id: 'git', - title: 'Git & Source Control', - description: 'Branch naming, base refs, attribution, and AI commit messages.', - icon: GitBranch, - // Why: the AI commit messages pane is rendered inside the Git section, - // so its search entries belong to Git too — that way a query like - // "claude" or "thinking" still surfaces the section. - searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES], - group: 'workflows' - }, - { - id: 'tasks', - title: 'Task Sources', - description: 'Choose which task providers appear in the Tasks page and sidebar.', - icon: ListChecks, - searchEntries: TASKS_PANE_SEARCH_ENTRIES, - group: 'workflows' - }, - { - id: 'floating-workspace', - title: 'Floating Workspace', - description: 'Global terminal, browser, and markdown tabs.', - icon: PanelsTopLeft, - searchEntries: FLOATING_WORKSPACE_SEARCH_ENTRIES, - group: 'workflows' - }, - { - id: 'appearance', - title: 'Appearance', - description: 'Theme, zoom, app font, sidebars, and status bar.', - icon: Palette, - searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES, - group: 'interface' - }, - { - id: 'input', - title: 'Input & Editing', - description: 'Selection and editing behavior.', - icon: TextCursorInput, - searchEntries: INPUT_PANE_SEARCH_ENTRIES, - group: 'interface' - }, - { - id: 'terminal', - title: 'Terminal', - description: 'Shells, terminal appearance, and pane behavior.', - icon: SquareTerminal, - searchEntries: terminalPaneSearchEntries, - group: 'workflows' - }, - { - id: 'quick-commands', - title: 'Quick Commands', - description: 'Saved terminal commands, scoped globally or per project.', - icon: Play, - searchEntries: QUICK_COMMANDS_PANE_SEARCH_ENTRIES, - group: 'workflows' - }, - ...(showDesktopOnlySettings - ? [ - { - id: 'browser' as const, - title: 'Browser', - description: 'Home page, link routing, and session cookies.', - icon: Globe, - searchEntries: BROWSER_PANE_SEARCH_ENTRIES, - group: 'workflows' - }, - { - id: 'notifications' as const, - title: 'Notifications', - description: 'Native desktop notifications for agent and terminal events.', - icon: Bell, - searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES, - group: 'interface' - } - ] - : []), - { - id: 'orchestration', - title: 'Orchestration', - description: 'Coordinate multiple coding agents through Orca.', - icon: Network, - searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES, - group: 'capabilities' - }, - { - id: 'servers', - title: 'Remote Orca Servers', - description: isWebClient - ? 'Connect this browser to a saved Orca server.' - : 'Switch between local desktop mode and paired remote Orca runtimes.', - icon: Server, - searchEntries: [runtimeEnvironmentsSearchEntry], - group: 'remote', - badge: 'Beta' - }, - ...(showDesktopOnlySettings - ? [ - { - id: 'ssh' as const, - title: 'SSH Hosts', - description: 'Remote SSH hosts for files, terminals, and git.', - icon: Cable, - searchEntries: SSH_PANE_SEARCH_ENTRIES, - group: 'remote' - }, - { - id: 'mobile' as const, - title: 'Mobile', - description: 'Control terminals and agents from your phone.', - icon: Smartphone, - searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES, - group: 'remote' - }, - { - id: 'computer-use' as const, - title: 'Computer Use', - description: 'Enable agents to control any app on your computer.', - icon: MousePointerClick, - searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES, - group: 'capabilities', - badge: 'Beta' - }, - { - id: 'voice' as const, - title: 'Voice', - description: 'Local speech-to-text dictation with on-device models.', - icon: Mic, - searchEntries: VOICE_PANE_SEARCH_ENTRIES, - group: 'capabilities', - badge: 'Beta' - } - ] - : []), - ...(showDesktopOnlySettings && isMac - ? [ - { - id: 'developer-permissions' as const, - title: 'macOS Permissions', - description: 'macOS privacy access for terminal-launched developer tools.', - icon: ShieldCheck, - searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES, - group: 'safety' - } - ] - : []), - { - id: 'privacy', - title: 'Privacy & Telemetry', - description: 'Anonymous usage data and telemetry controls.', - icon: Lock, - searchEntries: PRIVACY_PANE_SEARCH_ENTRIES, - group: 'safety' - }, - { - id: 'shortcuts', - title: 'Shortcuts', - description: 'Keyboard shortcuts for common actions.', - icon: Keyboard, - searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES, - group: 'interface' - }, - { - id: 'stats', - title: 'Stats & Usage', - description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.', - icon: BarChart3, - searchEntries: STATS_PANE_SEARCH_ENTRIES, - group: 'interface' - }, - { - id: 'experimental', - title: 'Experimental', - description: 'New features that are still taking shape. Give them a try.', - icon: FlaskConical, - searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES, - group: 'experimental' - }, - ...repos.map((repo) => ({ - id: `repo-${repo.id}`, - title: repo.displayName, - description: `${getRepoKindLabel(repo)} • ${repo.path}`, - icon: SlidersHorizontal, - searchEntries: getRepositoryPaneSearchEntries(repo), - group: 'repositories' - })) - ], - [ - isMac, - isWebClient, - repos, - runtimeEnvironmentsSearchEntry, - showDesktopOnlySettings, - terminalPaneSearchEntries - ] + const navSections = useSettingsNavigationMetadata() + const navSectionById = useMemo( + () => new Map(navSections.map((section) => [section.id, section] as const)), + [navSections] ) + const getSectionSearchEntries = (sectionId: string) => + navSectionById.get(sectionId)?.searchEntries ?? [] const visibleNavSections = useMemo( () => @@ -996,7 +683,7 @@ function Settings(): React.JSX.Element { id="general" title="General" description="Workspace defaults, app setup, and maintenance." - searchEntries={GENERAL_PANE_SEARCH_ENTRIES} + searchEntries={getSectionSearchEntries('general')} > {isSectionMounted('general') ? ( @@ -1007,7 +694,7 @@ function Settings(): React.JSX.Element { id="agents" title="Agents" description="Manage AI agents, set a default, and customize commands." - searchEntries={AGENTS_PANE_SEARCH_ENTRIES} + searchEntries={getSectionSearchEntries('agents')} > {isSectionMounted('agents') ? ( @@ -1019,7 +706,7 @@ function Settings(): React.JSX.Element { title="AI Provider Accounts" description="Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them." badge="Optional" - searchEntries={ACCOUNTS_PANE_SEARCH_ENTRIES} + searchEntries={getSectionSearchEntries('accounts')} > {isSectionMounted('accounts') ? ( @@ -1030,7 +717,7 @@ function Settings(): React.JSX.Element { id="integrations" title="Integrations" description="Connect GitHub, GitLab, Linear, and source-hosting services." - searchEntries={INTEGRATIONS_PANE_SEARCH_ENTRIES} + searchEntries={getSectionSearchEntries('integrations')} > {isSectionMounted('integrations') ? : null} @@ -1039,10 +726,7 @@ function Settings(): React.JSX.Element { id="git" title="Git & Source Control" description="Branch naming, base refs, attribution, and AI commit messages." - searchEntries={[ - ...GIT_PANE_SEARCH_ENTRIES, - ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES - ]} + searchEntries={getSectionSearchEntries('git')} forceVisible={hasUnsavedCommitPromptChanges} > {isSectionMounted('git') ? ( @@ -1066,7 +750,7 @@ function Settings(): React.JSX.Element { id="tasks" title="Task Sources" description="Choose which task providers appear in the Tasks page and sidebar." - searchEntries={TASKS_PANE_SEARCH_ENTRIES} + searchEntries={getSectionSearchEntries('tasks')} > {isSectionMounted('tasks') ? ( @@ -1077,7 +761,7 @@ function Settings(): React.JSX.Element { id="floating-workspace" title="Floating Workspace" description="Global terminal, browser, and markdown tabs." - searchEntries={FLOATING_WORKSPACE_SEARCH_ENTRIES} + searchEntries={getSectionSearchEntries('floating-workspace')} > {isSectionMounted('floating-workspace') ? ( @@ -1088,7 +772,7 @@ function Settings(): React.JSX.Element { id="terminal" title="Terminal" description="Shells, terminal appearance, and pane behavior." - searchEntries={terminalPaneSearchEntries} + searchEntries={getSectionSearchEntries('terminal')} headerAction={