Add Cmd-J settings and quick actions (#2769)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-24 20:05:46 -07:00 committed by GitHub
parent 99d580ddf8
commit 3ef4a42305
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2610 additions and 1158 deletions

View File

@ -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 <body>), 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) => {

View File

@ -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<string | null>(null)
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview')
const activeGroupSnapshotRef = useRef<CmdJActiveGroupSnapshot | null>(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<PaletteListEntry[]>(() => {
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<PaletteItem[]>(
() =>
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<PaletteListEntry[]>(() => {
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 {
}}
>
<CommandInput
placeholder={'Jump to worktree or browser tab\u2026 try "repo/worktree"'}
placeholder="Search workspaces, settings, tabs, and actions..."
value={query}
onValueChange={setQuery}
wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
@ -911,7 +1100,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
className="h-12 text-[14px] placeholder:text-muted-foreground/75"
/>
<CommandList ref={listRef} className="max-h-[min(460px,62vh)] px-2.5 pb-2.5 pt-2">
{isLoading ? (
{isLoading && selectableItems.length === 0 && !showCreateAction ? (
<PaletteState
title="Loading jump targets"
subtitle="Gathering your recent worktrees and open browser pages."
@ -947,6 +1136,26 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
)
}
if (entry.type === 'create-worktree') {
return (
<CommandItem
key={entry.id}
value={CREATE_WORKTREE_ITEM_ID}
onSelect={handleCreateWorktree}
className="group mx-0.5 mt-1 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-1.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"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-dashed border-border/60 bg-muted/25 text-muted-foreground/70">
<Plus size={13} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{`Create workspace "${createWorktreeName}"`}
</div>
</div>
</CommandItem>
)
}
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 (
<CommandItem
key={entry.id}
value={entry.id}
onSelect={() => 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'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
<Icon className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{result.title}
</span>
<span className="shrink-0 rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{kindLabel}
</span>
</div>
<div className="mt-1 truncate text-[12px] leading-5 text-muted-foreground/88">
{result.description}
</div>
</div>
</CommandItem>
)
}
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 {
</CommandItem>
)
})}
{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.
<CommandItem
value={CREATE_WORKTREE_ITEM_ID}
onSelect={handleCreateWorktree}
className="group mx-0.5 mt-1 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-1.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"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-dashed border-border/60 bg-muted/25 text-muted-foreground/70">
<Plus size={13} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{`Create workspace "${createWorktreeName}"`}
</div>
</div>
</CommandItem>
)}
</>
)}
</CommandList>

View File

@ -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()
})
})

View File

@ -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<string, string[]> = {
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)
}

View File

@ -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<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree'>
function ctx(
overrides: Partial<
Pick<CmdJQuickActionContext, 'activeGroupId' | 'activeWorktreeId' | 'isLoading' | 'sshStatus'>
>
): Pick<CmdJQuickActionContext, 'activeGroupId' | 'activeWorktreeId' | 'isLoading' | 'sshStatus'> {
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([])
})
})

View File

@ -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<void>
openNewMarkdownFile: (groupId: string) => Promise<void>
openNewTerminalTab: (groupId: string) => Promise<void>
openCreateWorkspace: () => void
openAddQuickCommand: () => void
}
export function resolveCmdJActiveGroupId(
state: Pick<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree'>,
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<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree'>,
worktreeId: string | null
): CmdJActiveGroupSnapshot | null {
if (!worktreeId) {
return null
}
return {
worktreeId,
groupId: resolveCmdJActiveGroupId(state, worktreeId)
}
}
export function getActiveWorktreeSshStatus(
state: Pick<AppState, 'repos' | 'sshConnectionStates' | 'worktreesByRepo'>,
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<void>
openNewMarkdownFile: (groupId: string) => Promise<void>
openNewTerminalTab: (groupId: string) => Promise<void>
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.`
}
}

View File

@ -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<CmdJQuickActionAvailability, { available: true }>['reason']
}
export type CmdJQuickAction = {
id: string
kind: 'action'
title: string
description: string
icon: LucideIcon
verbKeywords: string[]
isAvailable: (ctx: CmdJQuickActionContext) => CmdJQuickActionAvailability
run: (ctx: CmdJQuickActionContext) => Promise<CmdJQuickActionRunResult>
}
function workspaceActionAvailability(ctx: CmdJQuickActionContext): CmdJQuickActionAvailability {
return getWorkspaceScopedActionAvailability(ctx)
}
async function runWorkspaceAction(
ctx: CmdJQuickActionContext,
run: (groupId: string) => Promise<void>
): Promise<CmdJQuickActionRunResult> {
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' }
}
}
]

View File

@ -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 <span className="text-xs text-muted-foreground">Unassigned</span>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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<GlobalSettings>) => void

View File

@ -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

View File

@ -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)
})
})

View File

@ -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<GlobalSettings>) => void
addCommandIntentSignal?: number
}
const GLOBAL_SCOPE_KEY = '__global__'
@ -43,6 +44,13 @@ function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): 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<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
@ -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<EditorState>(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)

View File

@ -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,

View File

@ -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<SettingsNavSection[]>(
() => [
{
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') ? (
<GeneralPane settings={settings} updateSettings={updateSettings} />
@ -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') ? (
<AgentsPane settings={settings} updateSettings={updateSettings} />
@ -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') ? (
<AccountsPane settings={settings} updateSettings={updateSettings} />
@ -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') ? <IntegrationsPane /> : null}
</SettingsSection>
@ -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') ? (
<TasksPane settings={settings} updateSettings={updateSettings} />
@ -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') ? (
<FloatingWorkspacePane settings={settings} updateSettings={updateSettings} />
@ -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={
<Button
variant="outline"
@ -1122,10 +806,14 @@ function Settings(): React.JSX.Element {
id="quick-commands"
title="Quick Commands"
description="Saved terminal commands, scoped globally or per project."
searchEntries={QUICK_COMMANDS_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('quick-commands')}
>
{isSectionMounted('quick-commands') ? (
<QuickCommandsPane settings={settings} updateSettings={updateSettings} />
<QuickCommandsPane
settings={settings}
updateSettings={updateSettings}
addCommandIntentSignal={quickCommandAddIntentSignal}
/>
) : null}
</SettingsSection>
@ -1134,7 +822,7 @@ function Settings(): React.JSX.Element {
id="browser"
title="Browser"
description="Home page, link routing, and session cookies."
searchEntries={BROWSER_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('browser')}
>
{isSectionMounted('browser') ? (
<BrowserPane
@ -1150,7 +838,7 @@ function Settings(): React.JSX.Element {
id="appearance"
title="Appearance"
description="Theme, zoom, app font, sidebars, and status bar."
searchEntries={APPEARANCE_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('appearance')}
>
{isSectionMounted('appearance') ? (
<AppearancePane
@ -1166,7 +854,7 @@ function Settings(): React.JSX.Element {
id="input"
title="Input & Editing"
description="Selection and editing behavior."
searchEntries={INPUT_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('input')}
>
<InputPane settings={settings} updateSettings={updateSettings} />
</SettingsSection>
@ -1176,7 +864,7 @@ function Settings(): React.JSX.Element {
id="notifications"
title="Notifications"
description="Native desktop notifications for agent activity and terminal events."
searchEntries={NOTIFICATIONS_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('notifications')}
>
{isSectionMounted('notifications') ? (
<NotificationsPane settings={settings} updateSettings={updateSettings} />
@ -1188,7 +876,7 @@ function Settings(): React.JSX.Element {
id="shortcuts"
title="Shortcuts"
description="Keyboard shortcuts for common actions."
searchEntries={SHORTCUTS_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('shortcuts')}
>
{isSectionMounted('shortcuts') ? <ShortcutsPane /> : null}
</SettingsSection>
@ -1197,7 +885,7 @@ function Settings(): React.JSX.Element {
id="stats"
title="Stats & Usage"
description="Orca stats plus Claude, Codex, and OpenCode usage analytics."
searchEntries={STATS_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('stats')}
>
{isSectionMounted('stats') ? <StatsPane /> : null}
</SettingsSection>
@ -1206,7 +894,7 @@ function Settings(): React.JSX.Element {
id="orchestration"
title="Orchestration"
description="Coordinate multiple coding agents through Orca."
searchEntries={ORCHESTRATION_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('orchestration')}
>
{isSectionMounted('orchestration') ? <OrchestrationPane /> : null}
</SettingsSection>
@ -1241,7 +929,7 @@ function Settings(): React.JSX.Element {
) : null
}
description="Enable agents to control any app on your computer."
searchEntries={COMPUTER_USE_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('computer-use')}
>
{isSectionMounted('computer-use') ? <ComputerUsePane /> : null}
</SettingsSection>
@ -1251,7 +939,7 @@ function Settings(): React.JSX.Element {
title="Voice"
badge="Beta"
description="Local speech-to-text dictation with on-device models."
searchEntries={VOICE_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('voice')}
>
{isSectionMounted('voice') ? (
<VoicePane settings={settings} updateSettings={updateSettings} />
@ -1269,7 +957,7 @@ function Settings(): React.JSX.Element {
? 'Connect this browser to a saved Orca server.'
: 'Switch between local desktop mode and paired remote Orca runtimes.'
}
searchEntries={[runtimeEnvironmentsSearchEntry]}
searchEntries={getSectionSearchEntries('servers')}
>
{isSectionMounted('servers') ? (
<RuntimeEnvironmentsPane
@ -1287,7 +975,7 @@ function Settings(): React.JSX.Element {
id="ssh"
title="SSH Hosts"
description="Remote SSH hosts for files, terminals, and git."
searchEntries={SSH_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('ssh')}
>
{isSectionMounted('ssh') ? <SshPane /> : null}
</SettingsSection>
@ -1297,7 +985,7 @@ function Settings(): React.JSX.Element {
title="Mobile"
badge="Beta"
description="Control terminals and agents from your phone."
searchEntries={MOBILE_SETTINGS_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('mobile')}
>
{isSectionMounted('mobile') ? (
<MobileSettingsPane settings={settings} updateSettings={updateSettings} />
@ -1311,7 +999,7 @@ function Settings(): React.JSX.Element {
id="developer-permissions"
title="macOS Permissions"
description="macOS privacy access for terminal-launched developer tools."
searchEntries={DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('developer-permissions')}
>
{isSectionMounted('developer-permissions') ? (
<DeveloperPermissionsPane />
@ -1323,7 +1011,7 @@ function Settings(): React.JSX.Element {
id="privacy"
title="Privacy & Telemetry"
description="Anonymous usage data and telemetry controls."
searchEntries={PRIVACY_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('privacy')}
>
{isSectionMounted('privacy') ? <PrivacyPane settings={settings} /> : null}
</SettingsSection>
@ -1332,7 +1020,7 @@ function Settings(): React.JSX.Element {
id="experimental"
title="Experimental"
description="New features that are still taking shape. Give them a try."
searchEntries={EXPERIMENTAL_PANE_SEARCH_ENTRIES}
searchEntries={getSectionSearchEntries('experimental')}
>
{isSectionMounted('experimental') ? (
<ExperimentalPane
@ -1353,7 +1041,7 @@ function Settings(): React.JSX.Element {
id={repoSectionId}
title={`Project Settings > ${repo.displayName}`}
description={repo.path}
searchEntries={getRepositoryPaneSearchEntries(repo)}
searchEntries={getSectionSearchEntries(repoSectionId)}
>
{isSectionMounted(repoSectionId) ? (
<RepositoryPane

View File

@ -24,6 +24,12 @@ import { SearchableSetting } from './SearchableSetting'
import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls'
import { ShortcutBindingRow, type ShortcutTerminalStatus } from './ShortcutBindingRow'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import {
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY,
SHORTCUTS_PANE_SEARCH_ENTRIES,
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY
} from './shortcuts-search'
export { SHORTCUTS_PANE_SEARCH_ENTRIES }
type ShortcutGroup = {
title: string
@ -37,38 +43,6 @@ const platform: NodeJS.Platform = isMac
? 'win32'
: 'linux'
const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Recent Tab Order',
description: 'Choose recent or sequential tab switching.',
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
}
const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Shortcuts in Terminal',
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
keywords: [
'shortcut',
'keyboard',
'terminal',
'tui',
'shell',
'agent',
'conflict',
'orca first',
'terminal first'
]
}
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...KEYBINDING_DEFINITIONS.map((item) => ({
title: item.title,
description: `${item.group} shortcut`,
keywords: [...item.searchKeywords]
})),
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
]
function groupDefinitions(): ShortcutGroup[] {
const groups = new Map<string, KeybindingDefinition[]>()
for (const definition of KEYBINDING_DEFINITIONS) {

View File

@ -10,34 +10,11 @@ import {
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants'
import { useAppStore } from '@/store'
import { Button } from '../ui/button'
import type { SettingsSearchEntry } from './settings-search'
import { removeSshTargetWithBestEffortCleanup } from './ssh-target-remove'
import { SshTargetCard } from './SshTargetCard'
import { SshTargetDestructiveActions } from './SshTargetDestructiveActions'
import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm'
export const SSH_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'SSH Connections',
description: 'Manage remote SSH targets.',
keywords: ['ssh', 'remote', 'server', 'connection', 'host']
},
{
title: 'Add SSH Target',
description: 'Add a new remote SSH target.',
keywords: ['ssh', 'add', 'new', 'target', 'host', 'server']
},
{
title: 'Import from SSH Config',
description: 'Import hosts from ~/.ssh/config.',
keywords: ['ssh', 'import', 'config', 'hosts']
},
{
title: 'Test Connection',
description: 'Test connectivity to an SSH target.',
keywords: ['ssh', 'test', 'connection', 'ping']
}
]
export { SSH_PANE_SEARCH_ENTRIES } from './ssh-search'
type SshPaneProps = Record<string, never>

View File

@ -0,0 +1,135 @@
import type { StatusBarItem } from '../../../../shared/types'
import type { SettingsSearchEntry } from './settings-search'
export 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.'
}
]
export const THEME_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Theme',
description: 'Choose how Orca looks in the app window.',
keywords: ['dark', 'light', 'system']
}
]
export const ZOOM_ENTRIES: SettingsSearchEntry[] = [
{
title: 'UI Zoom',
description: 'Scale the entire application interface.',
keywords: ['zoom', 'scale', 'shortcut']
}
]
export 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']
}
]
export 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']
}
]
export const TITLEBAR_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Titlebar App Name',
description: 'Show Orca in the titlebar.',
keywords: ['titlebar', 'orca', 'app', 'name', 'brand']
}
]
export const STATUS_BAR_ENTRIES: SettingsSearchEntry[] = STATUS_BAR_TOGGLES.map(
({ title, description, keywords }) => ({ title, description, keywords })
)
export 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
]

View File

@ -0,0 +1,8 @@
import type { SettingsSearchEntry } 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'
export const BROWSER_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...BROWSER_USE_PANE_SEARCH_ENTRIES,
...BROWSER_CORE_SEARCH_ENTRIES
]

View File

@ -0,0 +1,16 @@
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'
]
}
]

View File

@ -0,0 +1,29 @@
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']
}
]

View File

@ -0,0 +1,22 @@
import type { SettingsSearchEntry } from './settings-search'
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'
]
}
]

View File

@ -0,0 +1,34 @@
import type { SettingsSearchEntry } from './settings-search'
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']
}
]

View File

@ -0,0 +1,49 @@
import type { SettingsSearchEntry } from './settings-search'
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'
]
}
]

View File

@ -0,0 +1,25 @@
import type { SettingsSearchEntry } from './settings-search'
import { MOBILE_PANE_SEARCH_ENTRIES } from './mobile-pane-search'
export 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
]

View File

@ -0,0 +1,52 @@
import type { SettingsSearchEntry } from './settings-search'
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']
}
]

View File

@ -0,0 +1,154 @@
import type { Repo } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import type { SettingsSearchEntry } from './settings-search'
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'
]
}
])
]
}

View File

@ -0,0 +1,34 @@
import { KEYBINDING_DEFINITIONS } from '../../../../shared/keybindings'
import type { SettingsSearchEntry } from './settings-search'
export const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Recent Tab Order',
description: 'Choose recent or sequential tab switching.',
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
}
export const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Shortcuts in Terminal',
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
keywords: [
'shortcut',
'keyboard',
'terminal',
'tui',
'shell',
'agent',
'conflict',
'orca first',
'terminal first'
]
}
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...KEYBINDING_DEFINITIONS.map((item) => ({
title: item.title,
description: `${item.group} shortcut`,
keywords: [...item.searchKeywords]
})),
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
]

View File

@ -0,0 +1,24 @@
import type { SettingsSearchEntry } from './settings-search'
export const SSH_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'SSH Connections',
description: 'Manage remote SSH targets.',
keywords: ['ssh', 'remote', 'server', 'connection', 'host']
},
{
title: 'Add SSH Target',
description: 'Add a new remote SSH target.',
keywords: ['ssh', 'add', 'new', 'target', 'host', 'server']
},
{
title: 'Import from SSH Config',
description: 'Import hosts from ~/.ssh/config.',
keywords: ['ssh', 'import', 'config', 'hosts']
},
{
title: 'Test Connection',
description: 'Test connectivity to an SSH target.',
keywords: ['ssh', 'test', 'connection', 'ping']
}
]

View File

@ -6,7 +6,6 @@ import { ClaudeUsagePane } from './ClaudeUsagePane'
import { CodexUsagePane } from './CodexUsagePane'
import { OpenCodeUsagePane } from './OpenCodeUsagePane'
import { UsageOverviewPane } from './UsageOverviewPane'
import type { SettingsSearchEntry } from '../settings/settings-search'
import { Button } from '../ui/button'
import {
DropdownMenu,
@ -15,28 +14,7 @@ import {
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { AgentIcon } from '@/lib/agent-catalog'
export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Stats & Usage',
description:
'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.',
keywords: [
'stats',
'usage',
'statistics',
'agents',
'prs',
'time',
'tracking',
'claude',
'codex',
'opencode',
'tokens',
'cache'
]
}
]
export { STATS_PANE_SEARCH_ENTRIES } from './stats-search'
function formatDuration(ms: number): string {
if (ms <= 0) {

View File

@ -0,0 +1,23 @@
import type { SettingsSearchEntry } from '../settings/settings-search'
export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Stats & Usage',
description:
'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.',
keywords: [
'stats',
'usage',
'statistics',
'agents',
'prs',
'time',
'tracking',
'claude',
'codex',
'opencode',
'tokens',
'cache'
]
}
]

View File

@ -2,7 +2,6 @@
group-scoped activation, close, split, and tab-order rules together so the extracted
controller cannot drift from the TabGroupPanel surface it coordinates. */
import { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import type { OpenFile } from '@/store/slices/editor'
import type {
@ -12,10 +11,6 @@ import type {
TerminalTab
} from '../../../../shared/types'
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 { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
import { requestEditorFileClose } from '../editor/editor-autosave'
import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface'
@ -45,7 +40,6 @@ export function useTabGroupWorkspaceModel({
groupId: string
worktreeId: string
}) {
const allWorktrees = useAllWorktrees()
const worktreeState = useAppStore(
useShallow((state) => ({
// Why: Zustand v5 expects selector snapshots to be referentially stable
@ -72,6 +66,15 @@ export function useTabGroupWorkspaceModel({
const setActiveFile = useAppStore((state) => state.setActiveFile)
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
const createBrowserTab = useAppStore((state) => state.createBrowserTab)
const openNewBrowserTabInActiveWorkspace = useAppStore(
(state) => state.openNewBrowserTabInActiveWorkspace
)
const openNewMarkdownInActiveWorkspace = useAppStore(
(state) => state.openNewMarkdownInActiveWorkspace
)
const openNewTerminalTabInActiveWorkspace = useAppStore(
(state) => state.openNewTerminalTabInActiveWorkspace
)
const closeFile = useAppStore((state) => state.closeFile)
const pinFile = useAppStore((state) => state.pinFile)
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
@ -81,16 +84,11 @@ export function useTabGroupWorkspaceModel({
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)
const openFile = useAppStore((state) => state.openFile)
const group = useMemo(
() => worktreeState.groups.find((item) => item.id === groupId) ?? null,
[groupId, worktreeState.groups]
)
const worktree = useMemo(
() => allWorktrees.find((candidate) => candidate.id === worktreeId) ?? null,
[allWorktrees, worktreeId]
)
const groupTabs = useMemo(
() => worktreeState.unifiedTabs.filter((item) => item.groupId === groupId),
[groupId, worktreeState.unifiedTabs]
@ -533,24 +531,7 @@ export function useTabGroupWorkspaceModel({
closeToRight,
createSplitGroup,
newBrowserTab: () => {
void (async () => {
const state = useAppStore.getState()
const defaultUrl = state.browserDefaultUrl ?? 'about:blank'
if (
await createWebRuntimeSessionBrowserTab({
worktreeId,
url: defaultUrl,
targetGroupId: groupId
})
) {
return
}
createBrowserTab(worktreeId, defaultUrl, {
title: 'New Browser Tab',
focusAddressBar: true,
targetGroupId: groupId
})
})()
void openNewBrowserTabInActiveWorkspace(groupId)
},
duplicateBrowserTab: (browserTabId: string) => {
void (async () => {
@ -582,40 +563,10 @@ export function useTabGroupWorkspaceModel({
// assistive-tech activation because the "+" menu can be triggered from
// an unfocused panel without first updating global group focus.
newFileTab: async () => {
const path = worktree?.path
if (!path) {
return
}
try {
const connectionId = getConnectionId(worktreeId) ?? undefined
const settings = useAppStore.getState().settings
const fileInfo = await createUntitledMarkdownFile(
path,
worktreeId,
connectionId,
settings
)
openFile(fileInfo, { preview: false, targetGroupId: groupId })
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
}
await openNewMarkdownInActiveWorkspace(groupId)
},
newTerminalTab: () => {
void (async () => {
if (
await createWebRuntimeSessionTerminal({
worktreeId,
targetGroupId: groupId,
activate: true
})
) {
return
}
const terminal = createTab(worktreeId, groupId)
setActiveTab(terminal.id)
setActiveTabType('terminal')
focusTerminalTabSurface(terminal.id)
})()
void openNewTerminalTabInActiveWorkspace(groupId)
},
newTerminalWithShell: (shellOverride: string) => {
void (async () => {

View File

@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata'
import type { Repo } from '../../../shared/types'
const repo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0
} satisfies Repo
function ids(args: { isMac?: boolean; isWindows?: boolean; isWebClient?: boolean } = {}): string[] {
return buildSettingsNavigationMetadata({
isMac: args.isMac ?? false,
isWindows: args.isWindows ?? false,
isWebClient: args.isWebClient ?? false,
repos: [repo]
}).map((section) => section.id)
}
describe('settings navigation metadata', () => {
it('keeps desktop-only Settings panes out of web metadata', () => {
const webIds = ids({ isWebClient: true })
expect(webIds).not.toContain('browser')
expect(webIds).not.toContain('ssh')
expect(webIds).not.toContain('mobile')
expect(webIds).not.toContain('computer-use')
expect(webIds).not.toContain('voice')
expect(webIds).toContain('servers')
expect(webIds).toContain('repo-repo-1')
})
it('keeps macOS permissions mac-only', () => {
expect(ids({ isMac: false })).not.toContain('developer-permissions')
expect(ids({ isMac: true })).toContain('developer-permissions')
})
it('does not import Settings page or pane UI modules from the metadata hook', () => {
const testDir = dirname(fileURLToPath(import.meta.url))
const hookSource = readFileSync(resolve(testDir, 'useSettingsNavigationMetadata.ts'), 'utf8')
const importLines = hookSource
.split('\n')
.filter((line) => line.trim().startsWith('import '))
.join('\n')
expect(importLines).not.toMatch(/components\/settings\/Settings(?:'|")/)
expect(importLines).not.toMatch(/components\/settings\/[A-Z][A-Za-z]+Pane(?:'|")/)
expect(importLines).not.toMatch(/components\/stats\/StatsPane(?:'|")/)
})
it('does not import Settings page or pane UI modules from the quick action registry', () => {
const testDir = dirname(fileURLToPath(import.meta.url))
const registrySource = readFileSync(
resolve(testDir, '../components/cmd-j/quick-actions.ts'),
'utf8'
)
const importLines = registrySource
.split('\n')
.filter((line) => line.trim().startsWith('import '))
.join('\n')
expect(importLines).not.toMatch(/components\/settings\/Settings(?:'|")/)
expect(importLines).not.toMatch(/components\/settings\/[A-Z][A-Za-z]+Pane(?:'|")/)
expect(importLines).not.toMatch(/components\/stats\/StatsPane(?:'|")/)
})
})

View File

@ -0,0 +1,331 @@
/* oxlint-disable max-lines */
import { useMemo } from 'react'
// Why: this registry mirrors the Settings sidebar in one neutral module so
// Cmd+J and Settings visibility cannot drift. Keep it free of Settings pane UI
// imports; the boundary is enforced by a focused architecture test.
import {
BarChart3,
Bell,
Blocks,
Bot,
Cable,
FlaskConical,
GitBranch,
Globe,
Keyboard,
ListChecks,
Lock,
Mic,
MousePointerClick,
Network,
Palette,
PanelsTopLeft,
Play,
Server,
ShieldCheck,
SlidersHorizontal,
Smartphone,
SquareTerminal,
TextCursorInput,
UserCog
} from 'lucide-react'
import type { Repo } from '../../../shared/types'
import { getRepoKindLabel } from '../../../shared/repo-kind'
import { useAppStore } from '@/store'
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
import { GENERAL_PANE_SEARCH_ENTRIES } from '@/components/settings/general-search'
import { AGENTS_PANE_SEARCH_ENTRIES } from '@/components/settings/agents-search'
import { ACCOUNTS_PANE_SEARCH_ENTRIES } from '@/components/settings/accounts-search'
import { INTEGRATIONS_PANE_SEARCH_ENTRIES } from '@/components/settings/integrations-search'
import { GIT_PANE_SEARCH_ENTRIES } from '@/components/settings/git-search'
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from '@/components/settings/commit-message-ai-search'
import { TASKS_PANE_SEARCH_ENTRIES } from '@/components/settings/tasks-search'
import { FLOATING_WORKSPACE_SEARCH_ENTRIES } from '@/components/settings/floating-workspace-search'
import { APPEARANCE_PANE_SEARCH_ENTRIES } from '@/components/settings/appearance-search'
import { INPUT_PANE_SEARCH_ENTRIES } from '@/components/settings/input-search'
import { getTerminalPaneSearchEntries } from '@/components/settings/terminal-search'
import { QUICK_COMMANDS_PANE_SEARCH_ENTRIES } from '@/components/settings/quick-commands-search'
import { BROWSER_PANE_SEARCH_ENTRIES } from '@/components/settings/browser-pane-search'
import { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from '@/components/settings/notifications-search'
import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from '@/components/settings/orchestration-search'
import {
RUNTIME_ENVIRONMENTS_SEARCH_ENTRY,
WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
} from '@/components/settings/runtime-environments-search'
import { SSH_PANE_SEARCH_ENTRIES } from '@/components/settings/ssh-search'
import { MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } from '@/components/settings/mobile-settings-search'
import { COMPUTER_USE_PANE_SEARCH_ENTRIES } from '@/components/settings/computer-use-search'
import { VOICE_PANE_SEARCH_ENTRIES } from '@/components/settings/voice-pane-search'
import { DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES } from '@/components/settings/developer-permissions-search'
import { PRIVACY_PANE_SEARCH_ENTRIES } from '@/components/settings/privacy-search'
import { SHORTCUTS_PANE_SEARCH_ENTRIES } from '@/components/settings/shortcuts-search'
import { STATS_PANE_SEARCH_ENTRIES } from '@/components/stats/stats-search'
import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from '@/components/settings/experimental-search'
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
export function isWebClientLocation(): boolean {
return (
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
window.location.pathname.endsWith('/web-index.html')
)
}
export function buildSettingsNavigationMetadata({
isMac,
isWindows,
isWebClient,
repos
}: {
isMac: boolean
isWindows: boolean
isWebClient: boolean
repos: readonly Repo[]
}): SettingsNavSection[] {
const showDesktopOnlySettings = !isWebClient
const terminalPaneSearchEntries = getTerminalPaneSearchEntries({
isWindows,
isMac
})
const runtimeEnvironmentsSearchEntry = isWebClient
? WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
: RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
return [
{
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 Git, so shared
// metadata must search both surfaces wherever Git appears.
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',
title: 'Browser',
description: 'Home page, link routing, and session cookies.',
icon: Globe,
searchEntries: BROWSER_PANE_SEARCH_ENTRIES,
group: 'workflows'
},
{
id: 'notifications',
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',
title: 'SSH Hosts',
description: 'Remote SSH hosts for files, terminals, and git.',
icon: Cable,
searchEntries: SSH_PANE_SEARCH_ENTRIES,
group: 'remote'
},
{
id: 'mobile',
title: 'Mobile',
description: 'Control terminals and agents from your phone.',
icon: Smartphone,
searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES,
group: 'remote'
},
{
id: 'computer-use',
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',
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',
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'
}))
]
}
export function useSettingsNavigationMetadata(): SettingsNavSection[] {
const repos = useAppStore((state) => state.repos)
const isMac = isMacUserAgent()
const isWindows = isWindowsUserAgent()
const isWebClient = isWebClientLocation()
// Why: Settings and Cmd+J share this metadata so platform/runtime visibility
// and search entries cannot drift. Keep this hook free of Settings pane UI
// imports; see docs/reference/cmd-j-settings-actions-plan.md.
return useMemo(
() => buildSettingsNavigationMetadata({ isMac, isWindows, isWebClient, repos }),
[isMac, isWindows, isWebClient, repos]
)
}

View File

@ -0,0 +1,45 @@
import type { LucideIcon } from 'lucide-react'
import type { SettingsSearchEntry } from '@/components/settings/settings-search'
export 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'
| 'experimental'
| 'agents'
| 'orchestration'
| 'servers'
| 'mobile'
| 'repo'
export type SettingsNavSection = {
id: string
title: string
description: string
icon: LucideIcon
searchEntries: SettingsSearchEntry[]
group: string
badge?: string
}
export type SettingsNavGroup = {
id: string
title: string
sections: SettingsNavSection[]
}

View File

@ -109,6 +109,7 @@ export type BrowserSlice = {
url: string,
options?: CreateBrowserTabOptions
) => BrowserWorkspace
openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise<void>
closeBrowserTab: (tabId: string) => void
shutdownWorktreeBrowsers: (worktreeId: string) => Promise<void>
reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null
@ -519,6 +520,34 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
return browserTab
},
openNewBrowserTabInActiveWorkspace: async (groupId) => {
const state = get()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return
}
const defaultUrl = state.browserDefaultUrl ?? 'about:blank'
const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean })
.__ORCA_WEB_CLIENT__
? state.settings?.activeRuntimeEnvironmentId?.trim()
: null
if (pairedWebRuntimeEnvironmentId) {
const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session')
await createWebRuntimeSessionBrowserTab({
worktreeId,
environmentId: pairedWebRuntimeEnvironmentId,
url: defaultUrl,
targetGroupId: groupId
})
return
}
get().createBrowserTab(worktreeId, defaultUrl, {
title: 'New Browser Tab',
focusAddressBar: true,
targetGroupId: groupId
})
},
closeBrowserTab: (tabId) => {
let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
set((s) => {

View File

@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers'
const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn())
const createWebRuntimeSessionTerminalMock = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/web-runtime-session', () => ({
createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock,
createWebRuntimeSessionTerminal: createWebRuntimeSessionTerminalMock
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: vi.fn()
}))
const pairedWebFlag = globalThis as { __ORCA_WEB_CLIENT__?: boolean }
function seedActiveWorkspace(store: ReturnType<typeof createTestStore>): void {
seedStore(store, {
activeWorktreeId: 'wt-1',
settings: { activeRuntimeEnvironmentId: 'runtime-1' } as AppState['settings'],
worktreesByRepo: {
[TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })]
},
groupsByWorktree: {
'wt-1': [{ id: 'group-1', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] }]
},
activeGroupIdByWorktree: { 'wt-1': 'group-1' }
})
}
describe('Cmd+J lifted creation actions', () => {
beforeEach(() => {
pairedWebFlag.__ORCA_WEB_CLIENT__ = true
createWebRuntimeSessionBrowserTabMock.mockReset()
createWebRuntimeSessionTerminalMock.mockReset()
})
afterEach(() => {
delete pairedWebFlag.__ORCA_WEB_CLIENT__
})
it('does not fall back to a local browser tab when paired-web creation fails', async () => {
createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false)
const store = createTestStore()
seedActiveWorkspace(store)
await store.getState().openNewBrowserTabInActiveWorkspace('group-1')
expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'runtime-1',
url: 'about:blank',
targetGroupId: 'group-1'
})
expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([])
})
it('does not fall back to a local terminal tab when paired-web creation fails', async () => {
createWebRuntimeSessionTerminalMock.mockResolvedValue(false)
const store = createTestStore()
seedActiveWorkspace(store)
await store.getState().openNewTerminalTabInActiveWorkspace('group-1')
expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'runtime-1',
targetGroupId: 'group-1',
activate: true
})
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([])
})
})

View File

@ -45,6 +45,8 @@ import {
} from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers'
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
export type DiffSource =
| 'unstaged'
@ -286,6 +288,7 @@ export type EditorSlice = {
suppressActiveRuntimeFallback?: boolean
}
) => void
openNewMarkdownInActiveWorkspace: (groupId: string) => Promise<void>
// Why: dispatcher for markdown link activation. Lives on the slice because it
// sequences openFile, setMarkdownViewMode, and setPendingEditorReveal around
// an async Monaco remount — all reading/writing state in this slice. See
@ -1380,6 +1383,31 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
)
},
openNewMarkdownInActiveWorkspace: async (groupId) => {
const state = get()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return
}
const worktree = state.getKnownWorktreeById(worktreeId)
if (!worktree) {
return
}
try {
const connectionId =
state.repos.find((entry) => entry.id === worktree.repoId)?.connectionId ?? undefined
const fileInfo = await createUntitledMarkdownFile(
worktree.path,
worktreeId,
connectionId,
get().settings
)
get().openFile(fileInfo, { preview: false, targetGroupId: groupId })
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
}
},
openMarkdownPreview: (file, options) => {
const initialState = get()
const resolvedRuntimeEnvironmentId =

View File

@ -37,6 +37,7 @@ import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
const usedOrdinals = new Set<number>()
@ -264,6 +265,7 @@ export type TerminalSlice = {
id?: string
}
) => TerminalTab
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise<void>
closeTab: (tabId: string) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
setTabBarOrder: (worktreeId: string, order: string[]) => void
@ -619,6 +621,52 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
return tab
},
openNewTerminalTabInActiveWorkspace: async (groupId) => {
const state = get()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return
}
const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean })
.__ORCA_WEB_CLIENT__
? state.settings?.activeRuntimeEnvironmentId?.trim()
: null
if (pairedWebRuntimeEnvironmentId) {
const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session')
await createWebRuntimeSessionTerminal({
worktreeId,
environmentId: pairedWebRuntimeEnvironmentId,
targetGroupId: groupId,
activate: true
})
return
}
const terminal = get().createTab(worktreeId, groupId)
get().setActiveTab(terminal.id)
get().setActiveTabType('terminal')
const latest = get()
const currentTerminals = latest.tabsByWorktree[worktreeId] ?? []
const currentEditors = latest.openFiles.filter((file) => file.worktreeId === worktreeId)
const currentBrowsers = latest.browserTabsByWorktree[worktreeId] ?? []
const stored = latest.tabBarOrderByWorktree[worktreeId]
const validIds = new Set([
...currentTerminals.map((tab) => tab.id),
...currentEditors.map((file) => file.id),
...currentBrowsers.map((tab) => tab.id)
])
const base = (stored ?? []).filter((id) => validIds.has(id))
const inBase = new Set(base)
for (const id of validIds) {
if (!inBase.has(id)) {
base.push(id)
}
}
// Why: Cmd+J uses the same creation path as the titlebar button, so a new
// terminal should append after mixed editor/browser tabs rather than jump first.
get().setTabBarOrder(worktreeId, [...base.filter((id) => id !== terminal.id), terminal.id])
focusTerminalTabSurface(terminal.id)
},
closeTab: (tabId) => {
set((s) => {
const next = { ...s.tabsByWorktree }

View File

@ -400,26 +400,32 @@ export type UISlice = {
pane:
| 'general'
| 'integrations'
| 'accounts'
| 'browser'
| 'git'
| 'appearance'
| 'input'
| 'tasks'
| 'floating-workspace'
| 'terminal'
| 'quick-commands'
| 'notifications'
| 'computer-use'
| 'developer-permissions'
| 'privacy'
| 'shortcuts'
| 'stats'
| 'repo'
| 'agents'
| 'accounts'
| 'voice'
| 'experimental'
| 'orchestration'
| 'servers'
| 'mobile'
| 'notifications'
| 'ssh'
repoId: string | null
sectionId?: string
intent?: 'add-quick-command'
} | null
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
clearSettingsTarget: () => void