Show local and remote Quick Commands by host (#13094)

* feat(quick-commands): support remote host collections

* fix(quick-commands): address remote host review

* Preserve local Quick Commands UI

---------

Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong 2026-08-08 07:29:20 -07:00 committed by GitHub
parent 54a9b5840b
commit 6da7b8e9cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1839 additions and 292 deletions

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { shouldOpenQuickCommandAddIntent } from './QuickCommandsPane'
import {
getAvailableQuickCommandHostId,
isQuickCommandEditorHostCurrent,
shouldOpenQuickCommandAddIntent,
shouldShowQuickCommandsRefreshError
} from './QuickCommandsPane'
describe('QuickCommandsPane add-command intent', () => {
it('opens the add flow once for each new intent signal', () => {
@ -10,3 +15,44 @@ describe('QuickCommandsPane add-command intent', () => {
expect(shouldOpenQuickCommandAddIntent(2, 1)).toBe(true)
})
})
describe('QuickCommandsPane host state', () => {
it('falls back to the local host when the selected remote host disappears', () => {
expect(
getAvailableQuickCommandHostId('runtime:removed', [
{ id: 'local' },
{ id: 'runtime:available' }
])
).toBe('local')
})
it('keeps the selected host while it remains available', () => {
expect(
getAvailableQuickCommandHostId('runtime:available', [
{ id: 'local' },
{ id: 'runtime:available' }
])
).toBe('runtime:available')
})
it('retires a remote editor when the same host reconnects with a new generation', () => {
expect(
isQuickCommandEditorHostCurrent(
'runtime:build',
3,
[{ id: 'local' }, { id: 'runtime:build' }],
new Map([['build', { connectionGeneration: 4 }]])
)
).toBe(false)
})
it('surfaces refresh failures only when cached commands remain usable', () => {
expect(shouldShowQuickCommandsRefreshError(true, { error: 'offline', ready: true })).toBe(true)
expect(shouldShowQuickCommandsRefreshError(true, { error: 'offline', ready: false })).toBe(
false
)
expect(shouldShowQuickCommandsRefreshError(false, { error: 'offline', ready: true })).toBe(
false
)
})
})

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus } from 'lucide-react'
import type { GlobalSettings, TerminalQuickCommand } from '../../../../shared/types'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
@ -14,10 +14,20 @@ import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
import { QuickCommandsList } from './QuickCommandsList'
import { GLOBAL_SCOPE_KEY, QuickCommandsScopeFilter } from './QuickCommandsScopeFilter'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import {
getTerminalQuickCommandHostOptions,
shouldShowTerminalQuickCommandHostOwnership
} from '@/hooks/use-terminal-quick-command-hosts'
type QuickCommandsPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
addCommandIntentSignal?: number
}
@ -25,10 +35,14 @@ type EditorState =
| {
mode: 'add'
command: TerminalQuickCommand
connectionGeneration: number
hostId: ExecutionHostId
}
| {
mode: 'edit'
command: TerminalQuickCommand
connectionGeneration: number
hostId: ExecutionHostId
}
| null
@ -39,16 +53,75 @@ export function shouldOpenQuickCommandAddIntent(
return Boolean(addCommandIntentSignal && consumedAddIntentSignal !== addCommandIntentSignal)
}
export function getAvailableQuickCommandHostId(
selectedHostId: ExecutionHostId,
hostOptions: readonly { id: ExecutionHostId }[]
): ExecutionHostId {
return hostOptions.some((host) => host.id === selectedHostId)
? selectedHostId
: LOCAL_EXECUTION_HOST_ID
}
export function isQuickCommandEditorHostCurrent(
hostId: ExecutionHostId,
connectionGeneration: number,
hostOptions: readonly { id: ExecutionHostId }[],
runtimeStatuses: ReadonlyMap<string, { connectionGeneration?: number }>
): boolean {
const host = parseExecutionHostId(hostId)
return (
hostOptions.some((option) => option.id === hostId) &&
(host?.kind !== 'runtime' ||
(runtimeStatuses.get(host.environmentId)?.connectionGeneration ?? 0) === connectionGeneration)
)
}
export function shouldShowQuickCommandsRefreshError(
commandsAreCurrent: boolean,
runtimeCommands: { error: string | null; ready: boolean } | undefined
): boolean {
return commandsAreCurrent && runtimeCommands?.ready === true && Boolean(runtimeCommands.error)
}
export function QuickCommandsPane({
settings,
updateSettings,
addCommandIntentSignal
}: QuickCommandsPaneProps): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const commands = settings.terminalQuickCommands ?? []
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeStatuses = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const runtimeCommands = useAppStore((s) => s.runtimeTerminalQuickCommands)
const loadRuntimeCommands = useAppStore((s) => s.loadRuntimeTerminalQuickCommands)
const ownership = getSettingOwnershipSummary('terminalQuickCommands')
const confirm = useConfirmationDialog()
const [selectedHostId, setSelectedHostId] = useState<ExecutionHostId>(LOCAL_EXECUTION_HOST_ID)
const selectedHost = parseExecutionHostId(selectedHostId)
const selectedEnvironmentId = selectedHost?.kind === 'runtime' ? selectedHost.environmentId : null
const selectedRuntimeConnectionGeneration = selectedEnvironmentId
? (runtimeStatuses.get(selectedEnvironmentId)?.connectionGeneration ?? 0)
: 0
const selectedRuntimeCommands = selectedEnvironmentId
? runtimeCommands.get(selectedEnvironmentId)
: undefined
const selectedRuntimeCommandsAreCurrent =
selectedRuntimeCommands?.connectionGeneration === selectedRuntimeConnectionGeneration
const commands = selectedEnvironmentId
? selectedRuntimeCommandsAreCurrent
? (selectedRuntimeCommands?.commands ?? [])
: []
: (settings.terminalQuickCommands ?? [])
const canManageSelectedHost =
!selectedEnvironmentId ||
(selectedRuntimeCommandsAreCurrent && selectedRuntimeCommands?.supported === true)
useEffect(() => {
if (selectedEnvironmentId) {
void loadRuntimeCommands(selectedEnvironmentId, { force: true })
}
}, [loadRuntimeCommands, selectedEnvironmentId, selectedRuntimeConnectionGeneration])
const hostOptions = getTerminalQuickCommandHostOptions(settings, runtimeEnvironments)
const [editor, setEditor] = useState<EditorState>(null)
const consumedAddIntentSignalRef = useRef(0)
@ -58,11 +131,35 @@ export function QuickCommandsPane({
const [scopeSelection, setScopeSelection] = useState<ReadonlySet<string> | null>(null)
const [scopePopoverOpen, setScopePopoverOpen] = useState(false)
const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
const availableHostId = getAvailableQuickCommandHostId(selectedHostId, hostOptions)
const editorHostIsCurrent =
editor === null ||
isQuickCommandEditorHostCurrent(
editor.hostId,
editor.connectionGeneration,
hostOptions,
runtimeStatuses
)
if (availableHostId !== selectedHostId) {
setSelectedHostId(availableHostId)
setScopeSelection(null)
}
if (!editorHostIsCurrent) {
setEditor(null)
}
const hostRepos = useMemo(
() =>
selectedEnvironmentId
? repos.filter((repo) => getRepoExecutionHostId(repo) === selectedHostId)
: repos,
[repos, selectedEnvironmentId, selectedHostId]
)
const repoById = useMemo(() => new Map(hostRepos.map((repo) => [repo.id, repo])), [hostRepos])
const allScopeKeys = useMemo(
() => new Set<string>([GLOBAL_SCOPE_KEY, ...repos.map((r) => r.id)]),
[repos]
() => new Set<string>([GLOBAL_SCOPE_KEY, ...hostRepos.map((repo) => repo.id)]),
[hostRepos]
)
const effectiveSelection: ReadonlySet<string> = scopeSelection ?? allScopeKeys
const showAll = scopeSelection === null
@ -106,7 +203,12 @@ export function QuickCommandsPane({
// Why: Settings deep-links use this one-shot signal to open the add dialog;
// consume it before paint so the pane never flashes without the editor.
consumedAddIntentSignalRef.current = intentSignal
setEditor({ mode: 'add', command: createDraftForCurrentFilter() })
setEditor({
mode: 'add',
command: createDraftForCurrentFilter(),
connectionGeneration: selectedRuntimeConnectionGeneration,
hostId: selectedHostId
})
}
const toggleScope = (key: string): void => {
@ -135,15 +237,20 @@ export function QuickCommandsPane({
}
const saveCommand = (next: TerminalQuickCommand): void => {
// Why: re-read from the store so save lands on the latest list when
// multiple edit dialogs fire in quick succession.
const latest = useAppStore.getState().settings?.terminalQuickCommands ?? []
const isEdit = latest.some((command) => command.id === next.id)
const nextList = isEdit
? latest.map((command) => (command.id === next.id ? next : command))
: [...latest, next]
if (
!editor ||
!isQuickCommandEditorHostCurrent(
editor.hostId,
editor.connectionGeneration,
hostOptions,
runtimeStatuses
)
) {
setEditor(null)
return
}
useAppStore.getState().recordFeatureInteraction('quick-commands')
updateSettings({ terminalQuickCommands: nextList })
void useAppStore.getState().upsertTerminalQuickCommand(editor.hostId, next)
}
const removeCommand = async (command: TerminalQuickCommand): Promise<void> => {
@ -163,13 +270,7 @@ export function QuickCommandsPane({
if (!confirmed) {
return
}
// Why: re-read latest list from the store at delete time — the await above
// can span other settings changes, and a stale closure would resurrect
// commands that were removed concurrently.
const latest = useAppStore.getState().settings?.terminalQuickCommands ?? []
updateSettings({
terminalQuickCommands: latest.filter((c) => c.id !== command.id)
})
void useAppStore.getState().deleteTerminalQuickCommand(selectedHostId, command.id)
}
return (
@ -179,21 +280,62 @@ export function QuickCommandsPane({
<Label>
{translate('auto.components.settings.QuickCommandsPane.f91b649324', 'Saved Commands')}
</Label>
<p className="text-xs text-muted-foreground">{ownership.description}</p>
<p className="text-xs text-muted-foreground">
{shouldShowTerminalQuickCommandHostOwnership(hostOptions)
? ownership.description
: translate(
'auto.components.settings.settingOwnership.terminalQuickCommands',
'Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context.'
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditor({ mode: 'add', command: createDraftForCurrentFilter() })}
disabled={!canManageSelectedHost}
onClick={() =>
setEditor({
mode: 'add',
command: createDraftForCurrentFilter(),
connectionGeneration: selectedRuntimeConnectionGeneration,
hostId: selectedHostId
})
}
>
<Plus />
{translate('auto.components.settings.QuickCommandsPane.5aacc8f7dc', 'Add Command')}
</Button>
</div>
{shouldShowTerminalQuickCommandHostOwnership(hostOptions) ? (
<div className="space-y-2">
<Label htmlFor="quick-command-storage-host">
{translate('auto.components.settings.QuickCommandsPane.89f7e57fcc', 'Saved on')}
</Label>
<Select
value={selectedHostId}
onValueChange={(value) => {
setSelectedHostId(value as ExecutionHostId)
setScopeSelection(null)
}}
>
<SelectTrigger id="quick-command-storage-host" size="sm" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{hostOptions.map((host) => (
<SelectItem key={host.id} value={host.id}>
{host.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<QuickCommandsScopeFilter
repos={repos}
repos={hostRepos}
effectiveSelection={effectiveSelection}
showAll={showAll}
scopePopoverOpen={scopePopoverOpen}
@ -202,20 +344,90 @@ export function QuickCommandsPane({
toggleScope={toggleScope}
/>
<QuickCommandsList
commands={commands}
visibleCommands={visibleCommands}
repoById={repoById}
onEdit={(command) => setEditor({ mode: 'edit', command })}
onRemove={(command) => void removeCommand(command)}
/>
{selectedRuntimeCommandsAreCurrent && selectedRuntimeCommands?.supported === false ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{translate(
'auto.components.settings.QuickCommandsPane.d59bd333c3',
'Update this Orca server to manage its quick commands.'
)}
</div>
) : selectedRuntimeCommandsAreCurrent &&
selectedRuntimeCommands?.error &&
!selectedRuntimeCommands.ready ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 px-3 py-3">
<span className="text-sm text-muted-foreground">
{translate(
'auto.components.settings.QuickCommandsPane.f2bf411640',
'Could not load commands from this host.'
)}
</span>
<Button
type="button"
variant="outline"
size="xs"
onClick={() =>
selectedEnvironmentId &&
void loadRuntimeCommands(selectedEnvironmentId, { force: true })
}
>
{translate('auto.components.settings.QuickCommandsPane.7ecfee5b8e', 'Retry')}
</Button>
</div>
) : selectedEnvironmentId &&
(!selectedRuntimeCommandsAreCurrent ||
(selectedRuntimeCommands?.loading && !selectedRuntimeCommands.ready)) ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{translate('auto.components.settings.QuickCommandsPane.601d6af51f', 'Loading commands…')}
</div>
) : (
<>
{shouldShowQuickCommandsRefreshError(
selectedRuntimeCommandsAreCurrent,
selectedRuntimeCommands
) ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 px-3 py-3">
<span className="text-sm text-muted-foreground">
{translate(
'auto.components.settings.QuickCommandsPane.923ba89646',
'Could not refresh commands from this host. Showing the last loaded commands.'
)}
</span>
<Button
type="button"
variant="outline"
size="xs"
onClick={() =>
selectedEnvironmentId &&
void loadRuntimeCommands(selectedEnvironmentId, { force: true })
}
>
{translate('auto.components.settings.QuickCommandsPane.7ecfee5b8e', 'Retry')}
</Button>
</div>
) : null}
<QuickCommandsList
commands={commands}
visibleCommands={visibleCommands}
repoById={repoById}
onEdit={(command) =>
setEditor({
mode: 'edit',
command,
connectionGeneration: selectedRuntimeConnectionGeneration,
hostId: selectedHostId
})
}
onRemove={(command) => void removeCommand(command)}
/>
</>
)}
{editor !== null ? (
<TerminalQuickCommandDialog
open
mode={editor.mode}
command={editor.command}
repos={repos}
repos={hostRepos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={saveCommand}
/>

View File

@ -1546,7 +1546,6 @@ function Settings(): React.JSX.Element {
{isSectionMounted('quick-commands') ? (
<QuickCommandsPane
settings={settings}
updateSettings={updateSettings}
addCommandIntentSignal={quickCommandAddIntentSignal}
/>
) : null}

View File

@ -29,4 +29,12 @@ describe('getSettingOwnershipSummary', () => {
expect(getSettingOwnershipSummary('workspaceDirectory').ownership).toBe('host-override')
expect(getSettingOwnershipSummary('providerAccounts').ownership).toBe('provider-host')
})
it('documents quick commands as host-owned collections', () => {
const summary = getSettingOwnershipSummary('terminalQuickCommands')
expect(summary.ownership).toBe('host-collection')
expect(summary.description).toContain('selected Orca host')
expect(summary.description).toContain('remain available in remote workspaces')
})
})

View File

@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n'
export type SettingOwnership =
| 'client-default'
| 'host-override'
| 'host-collection'
| 'project-host-setup'
| 'provider-host'
@ -42,14 +43,14 @@ function buildSummaries(): Record<string, SettingOwnershipSummary> {
)
},
terminalQuickCommands: {
ownership: 'client-default',
ownership: 'host-collection',
label: translate(
'auto.components.settings.settingOwnership.clientDefaultProjectScopes',
'Client default + project scopes'
'auto.components.settings.settingOwnership.hostCollectionProjectScopes',
'Host collection + project scopes'
),
description: translate(
'auto.components.settings.settingOwnership.terminalQuickCommands',
'Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context.'
'auto.components.settings.settingOwnership.terminalQuickCommandHostCollections',
'Commands are saved on the selected Orca host, then scoped globally or to a project setup. Commands from this device also remain available in remote workspaces.'
)
},
workspaceDirectory: {

View File

@ -0,0 +1,36 @@
import { Play } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import type { TerminalQuickCommandHost } from '@/hooks/use-terminal-quick-command-hosts'
export function TabBarQuickCommandAddActions({
hosts,
onAdd
}: {
hosts: readonly TerminalQuickCommandHost[]
onAdd: (hostId: TerminalQuickCommandHost['hostId']) => void
}): React.JSX.Element {
return (
<div className="border-t border-border/50 p-1">
{hosts.map((host) => (
<button
key={host.hostId}
type="button"
onClick={() => onAdd(host.hostId)}
className="flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Play className="size-3.5" />
{hosts.length === 1
? translate(
'auto.components.tab.bar.TabBarQuickCommandAddActions.45a2f36d51',
'Command'
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandAddActions.b856c833ae',
'Command on {{value0}}',
{ value0: host.label }
)}
</button>
))}
</div>
)
}

View File

@ -0,0 +1,21 @@
import { translate } from '@/i18n/i18n'
export function TabBarQuickCommandHostLoadStatus({
failed
}: {
failed: boolean
}): React.JSX.Element {
return (
<div className="border-t border-border/50 px-3 py-2 text-[11px] text-muted-foreground">
{failed
? translate(
'auto.components.tab.bar.TabBarQuickCommandHostLoadStatus.82e294f3ca',
'Host unavailable'
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandHostLoadStatus.7c129b08ff',
'Loading host…'
)}
</div>
)
}

View File

@ -1,26 +1,29 @@
import { Pencil, Play, Trash2 } from 'lucide-react'
import { CommandItem } from '@/components/ui/command'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
import type { HostedTerminalQuickCommand } from '@/hooks/use-terminal-quick-command-hosts'
type TabBarQuickCommandItemProps = {
command: TerminalQuickCommand
entry: HostedTerminalQuickCommand
showHostLabel: boolean
onRun: () => void
onEdit: () => void
onDelete: () => void
}
export function TabBarQuickCommandItem({
command,
entry,
showHostLabel,
onRun,
onEdit,
onDelete
}: TabBarQuickCommandItemProps): React.JSX.Element {
const { command } = entry
return (
<CommandItem
value={command.id}
value={entry.key}
onSelect={onRun}
className="group/qc mx-1 my-0.5 cursor-pointer items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
>
@ -36,7 +39,14 @@ export function TabBarQuickCommandItem({
/>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{command.label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{command.label}
</span>
{showHostLabel ? (
<span className="shrink-0 text-[11px] text-muted-foreground">{entry.hostLabel}</span>
) : null}
</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${command.prompt}`

View File

@ -17,6 +17,12 @@ import type { TerminalQuickCommand } from '../../../../shared/types'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import { translate } from '@/i18n/i18n'
import { TabBarQuickCommandsMenu } from './TabBarQuickCommandsMenu'
import {
flattenTerminalQuickCommandHosts,
type HostedTerminalQuickCommand,
useTerminalQuickCommandHosts
} from '@/hooks/use-terminal-quick-command-hosts'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
type TabBarQuickCommandsButtonProps = {
worktreeId: string
@ -27,10 +33,10 @@ export function TabBarQuickCommandsButton({
worktreeId,
groupId
}: TabBarQuickCommandsButtonProps): React.JSX.Element | null {
const allCommands = useAppStore((s) => s.settings?.terminalQuickCommands)
const recentByGroup = useAppStore((s) => s.recentQuickCommandIdByGroup)
const updateSettings = useAppStore((s) => s.updateSettings)
const repos = useAppStore((s) => s.repos)
const { executionHostId, hosts, refreshRemoteHost, remoteHostLoadFailed, remoteHostPending } =
useTerminalQuickCommandHosts(worktreeId)
const confirm = useConfirmationDialog()
// Why: floating terminals share a synthetic worktree id (`global-floating-terminal`)
// that has no separator, so naive `getRepoIdFromWorktreeId` would return that
@ -45,21 +51,22 @@ export function TabBarQuickCommandsButton({
}, [worktreeId, repos])
const { repoCommands, globalCommands } = useMemo(() => {
const repoList: TerminalQuickCommand[] = []
const globalList: TerminalQuickCommand[] = []
for (const command of allCommands ?? []) {
const repoList: HostedTerminalQuickCommand[] = []
const globalList: HostedTerminalQuickCommand[] = []
for (const entry of flattenTerminalQuickCommandHosts(hosts)) {
const { command } = entry
if (!isTerminalQuickCommandComplete(command)) {
continue
}
const scope = getTerminalQuickCommandScope(command)
if (scope.type === 'global') {
globalList.push(command)
globalList.push(entry)
} else if (scope.type === 'repo' && repoId !== null && scope.repoId === repoId) {
repoList.push(command)
repoList.push(entry)
}
}
return { repoCommands: repoList, globalCommands: globalList }
}, [allCommands, repoId])
}, [hosts, repoId])
const recentId = recentByGroup[groupId] ?? null
// Why: split-button label prefers the most recently used command for this
@ -69,7 +76,10 @@ export function TabBarQuickCommandsButton({
const mostRecent = useMemo(() => {
if (recentId) {
const match =
repoCommands.find((c) => c.id === recentId) ?? globalCommands.find((c) => c.id === recentId)
repoCommands.find((entry) => entry.key === recentId) ??
globalCommands.find((entry) => entry.key === recentId) ??
repoCommands.find((entry) => entry.command.id === recentId) ??
globalCommands.find((entry) => entry.command.id === recentId)
if (match) {
return match
}
@ -78,29 +88,33 @@ export function TabBarQuickCommandsButton({
}, [repoCommands, globalCommands, recentId])
const [editor, setEditor] = useState<
| { mode: 'add'; command: TerminalQuickCommand }
| { mode: 'edit'; command: TerminalQuickCommand }
| { mode: 'add'; command: TerminalQuickCommand; hostId: ExecutionHostId }
| { mode: 'edit'; command: TerminalQuickCommand; hostId: ExecutionHostId }
| null
>(null)
const totalVisible = repoCommands.length + globalCommands.length
const hasAnyCommands = totalVisible > 0
const defaultHostId = hosts.some((host) => host.hostId === executionHostId)
? executionHostId
: hosts[0].hostId
const addRepoCommand = (): void => {
const addRepoCommand = (hostId: ExecutionHostId): void => {
setEditor({
mode: 'add',
hostId,
command: createTerminalQuickCommandDraft({ type: 'repo', repoId: repoId ?? '' })
})
}
const handleSaveCommand = (next: TerminalQuickCommand): void => {
const current = useAppStore.getState().settings?.terminalQuickCommands ?? []
const isEdit = current.some((c) => c.id === next.id)
const nextList = isEdit ? current.map((c) => (c.id === next.id ? next : c)) : [...current, next]
void updateSettings({ terminalQuickCommands: nextList })
if (editor) {
void useAppStore.getState().upsertTerminalQuickCommand(editor.hostId, next)
}
}
const handleDeleteCommand = async (command: TerminalQuickCommand): Promise<void> => {
const handleDeleteCommand = async (entry: HostedTerminalQuickCommand): Promise<void> => {
const { command } = entry
const confirmed = await confirm({
title: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.e8e1a52edb',
@ -120,13 +134,20 @@ export function TabBarQuickCommandsButton({
if (!confirmed) {
return
}
const current = useAppStore.getState().settings?.terminalQuickCommands ?? []
void updateSettings({ terminalQuickCommands: current.filter((c) => c.id !== command.id) })
void useAppStore.getState().deleteTerminalQuickCommand(entry.hostId, command.id)
}
const handleRun = (command: TerminalQuickCommand): void => {
runQuickCommandInNewTab({ command, worktreeId, groupId })
const handleRun = (entry: HostedTerminalQuickCommand): void => {
runQuickCommandInNewTab({
command: entry.command,
worktreeId,
groupId,
historyId: entry.key
})
}
const editorRepos = editor?.hostId.startsWith('runtime:')
? repos.filter((repo) => getRepoExecutionHostId(repo) === editor.hostId)
: repos
// Why: hidden in folder-mode worktrees (no repoId) and floating terminals.
// Without a repoId the button can't represent a repo-scoped run target, and
@ -137,14 +158,14 @@ export function TabBarQuickCommandsButton({
}
// Empty state: single button that opens the dialog directly.
if (!hasAnyCommands) {
if (!hasAnyCommands && hosts.length === 1 && !remoteHostPending) {
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={addRepoCommand}
onClick={() => addRepoCommand(defaultHostId)}
className="my-auto flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.8f1e971966',
@ -171,7 +192,7 @@ export function TabBarQuickCommandsButton({
open={editor !== null}
mode={editor?.mode ?? 'add'}
command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })}
repos={repos}
repos={editorRepos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={handleSaveCommand}
/>
@ -185,16 +206,22 @@ export function TabBarQuickCommandsButton({
repoCommands={repoCommands}
globalCommands={globalCommands}
mostRecent={mostRecent}
addHosts={hosts}
hostLoadFailed={remoteHostLoadFailed}
hostOwnershipPending={remoteHostPending}
onMenuOpen={refreshRemoteHost}
onAddCommand={addRepoCommand}
onEditCommand={(command) => setEditor({ mode: 'edit', command })}
onDeleteCommand={(command) => void handleDeleteCommand(command)}
onEditCommand={(entry) =>
setEditor({ mode: 'edit', command: entry.command, hostId: entry.hostId })
}
onDeleteCommand={(entry) => void handleDeleteCommand(entry)}
onRunCommand={handleRun}
/>
<TerminalQuickCommandDialog
open={editor !== null}
mode={editor?.mode ?? 'add'}
command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })}
repos={repos}
repos={editorRepos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={handleSaveCommand}
/>

View File

@ -128,9 +128,13 @@ function makeProps() {
repoCommands: [] as never[],
globalCommands: [] as never[],
mostRecent: null,
addHosts: [],
hostLoadFailed: false,
hostOwnershipPending: false,
onAddCommand: vi.fn(),
onDeleteCommand: vi.fn(),
onEditCommand: vi.fn(),
onMenuOpen: vi.fn(),
onRunCommand: vi.fn()
}
}

View File

@ -18,36 +18,46 @@ import {
getTerminalQuickCommandBody,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { getAgentLabel } from '@/lib/agent-catalog'
import { TabBarQuickCommandItem } from './TabBarQuickCommandItem'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import {
getTerminalQuickCommandPickerValue,
searchTerminalQuickCommands
} from '@/lib/terminal-quick-command-search'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { useTabBarQuickCommandsShortcut } from './tab-bar-quick-commands-shortcut'
import { TabBarQuickCommandAddActions } from './TabBarQuickCommandAddActions'
import { TabBarQuickCommandHostLoadStatus } from './TabBarQuickCommandHostLoadStatus'
import { searchHostedTerminalQuickCommands } from './hosted-terminal-quick-command-search'
import { useTabBarQuickCommandSearchInput } from './use-tab-bar-quick-command-search-input'
import type {
HostedTerminalQuickCommand,
TerminalQuickCommandHost
} from '@/hooks/use-terminal-quick-command-hosts'
type TabBarQuickCommandsMenuProps = {
repoCommands: readonly TerminalQuickCommand[]
globalCommands: readonly TerminalQuickCommand[]
mostRecent: TerminalQuickCommand | null
onAddCommand: () => void
onDeleteCommand: (command: TerminalQuickCommand) => void
onEditCommand: (command: TerminalQuickCommand) => void
onRunCommand: (command: TerminalQuickCommand) => void
repoCommands: readonly HostedTerminalQuickCommand[]
globalCommands: readonly HostedTerminalQuickCommand[]
mostRecent: HostedTerminalQuickCommand | null
addHosts: readonly TerminalQuickCommandHost[]
hostLoadFailed: boolean
hostOwnershipPending: boolean
onAddCommand: (hostId: TerminalQuickCommandHost['hostId']) => void
onDeleteCommand: (entry: HostedTerminalQuickCommand) => void
onEditCommand: (entry: HostedTerminalQuickCommand) => void
onMenuOpen: () => void
onRunCommand: (entry: HostedTerminalQuickCommand) => void
}
export function TabBarQuickCommandsMenu({
repoCommands,
globalCommands,
mostRecent,
addHosts,
hostLoadFailed,
hostOwnershipPending,
onAddCommand,
onDeleteCommand,
onEditCommand,
onMenuOpen,
onRunCommand
}: TabBarQuickCommandsMenuProps): React.JSX.Element {
const openMenuShortcutCombos = useShortcutKeyComboDetails('tab.openQuickCommandsMenu')
@ -62,14 +72,13 @@ export function TabBarQuickCommandsMenu({
// Why: closing restores focus to the chevron for accessibility, but that
// focus restoration should not immediately reopen its tooltip.
const suppressMoreCommandsTooltipRef = useRef(false)
const totalVisible = repoCommands.length + globalCommands.length
const showSearch = totalVisible > 1
const showSearch = repoCommands.length + globalCommands.length > 1
const filteredRepoCommands = useMemo(
() => searchTerminalQuickCommands(repoCommands, query),
() => searchHostedTerminalQuickCommands(repoCommands, query),
[repoCommands, query]
)
const filteredGlobalCommands = useMemo(
() => searchTerminalQuickCommands(globalCommands, query),
() => searchHostedTerminalQuickCommands(globalCommands, query),
[globalCommands, query]
)
const filteredVisibleCommands = useMemo(
@ -77,21 +86,22 @@ export function TabBarQuickCommandsMenu({
[filteredRepoCommands, filteredGlobalCommands]
)
const commandValue = useMemo(() => {
const activeValue = getTerminalQuickCommandPickerValue({
preferredCommandId: mostRecent?.id ?? null,
filteredCommands: filteredVisibleCommands,
rawQuery: query
})
const activeValue =
!query.trim() &&
mostRecent &&
filteredVisibleCommands.some((entry) => entry.key === mostRecent.key)
? mostRecent.key
: (filteredVisibleCommands[0]?.key ?? '')
if (
commandValueOverride &&
filteredVisibleCommands.some((command) => command.id === commandValueOverride)
filteredVisibleCommands.some((entry) => entry.key === commandValueOverride)
) {
return commandValueOverride
}
return activeValue
}, [commandValueOverride, filteredVisibleCommands, mostRecent?.id, query])
}, [commandValueOverride, filteredVisibleCommands, mostRecent, query])
const selectedCommand = useMemo(
() => filteredVisibleCommands.find((command) => command.id === commandValue) ?? null,
() => filteredVisibleCommands.find((entry) => entry.key === commandValue) ?? null,
[commandValue, filteredVisibleCommands]
)
const cancelFocusFrame = useCallback((): void => {
@ -126,6 +136,7 @@ export function TabBarQuickCommandsMenu({
(next: boolean): void => {
setMenuOpen(next)
if (next) {
onMenuOpen()
suppressMoreCommandsTooltipRef.current = false
setMoreCommandsTooltipOpen(false)
setCommandValueOverride(null)
@ -137,7 +148,7 @@ export function TabBarQuickCommandsMenu({
setQuery('')
setCommandValueOverride(null)
},
[cancelFocusFrame]
[cancelFocusFrame, onMenuOpen]
)
const closeMenu = useCallback((): void => {
handleOpenChange(false)
@ -153,9 +164,9 @@ export function TabBarQuickCommandsMenu({
return cancelFocusFrame
}, [cancelFocusFrame, focusSearchInput, menuOpen, showSearch])
const runAndClose = useCallback(
(command: TerminalQuickCommand): void => {
(entry: HostedTerminalQuickCommand): void => {
closeMenu()
onRunCommand(command)
onRunCommand(entry)
},
[closeMenu, onRunCommand]
)
@ -163,6 +174,7 @@ export function TabBarQuickCommandsMenu({
commandListRef,
commandValue,
filteredCommands: filteredVisibleCommands,
getCommandId: (entry) => entry.key,
onCommandValueChange: setCommandValueOverride,
onRun: runAndClose,
selectedCommand
@ -189,7 +201,7 @@ export function TabBarQuickCommandsMenu({
? translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b775303755',
'Run quick command: {{value0}}',
{ value0: mostRecent.label }
{ value0: mostRecent.command.label }
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
@ -199,26 +211,26 @@ export function TabBarQuickCommandsMenu({
>
<Play className="size-3 shrink-0" fill="currentColor" strokeWidth={0} />
<span className="max-w-[160px] truncate text-[12px] font-medium">
{mostRecent?.label ??
{mostRecent?.command.label ??
translate('auto.components.tab.bar.TabBarQuickCommandsButton.7b1c9d6ae1', 'Run')}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{mostRecent
? isTerminalAgentQuickCommand(mostRecent)
? isTerminalAgentQuickCommand(mostRecent.command)
? translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.77ac113df0',
'Start {{value0}}: {{value1}}',
{
value0: getAgentLabel(mostRecent.agent),
value1: getTerminalQuickCommandBody(mostRecent)
value0: getAgentLabel(mostRecent.command.agent),
value1: getTerminalQuickCommandBody(mostRecent.command)
}
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.37e1bb90ce',
'Run: {{value0}}',
{ value0: getTerminalQuickCommandBody(mostRecent) }
{ value0: getTerminalQuickCommandBody(mostRecent.command) }
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
@ -338,56 +350,53 @@ export function TabBarQuickCommandsMenu({
)}
</CommandEmpty>
) : null}
{filteredRepoCommands.map((command) => (
{filteredRepoCommands.map((entry) => (
<TabBarQuickCommandItem
key={command.id}
command={command}
onRun={() => runAndClose(command)}
key={entry.key}
entry={entry}
showHostLabel={addHosts.length > 1}
onRun={() => runAndClose(entry)}
onEdit={() => {
closeMenu()
onEditCommand(command)
onEditCommand(entry)
}}
onDelete={() => {
closeMenu()
onDeleteCommand(command)
onDeleteCommand(entry)
}}
/>
))}
{filteredRepoCommands.length > 0 && filteredGlobalCommands.length > 0 ? (
<CommandSeparator className="my-1" />
) : null}
{filteredGlobalCommands.map((command) => (
{filteredGlobalCommands.map((entry) => (
<TabBarQuickCommandItem
key={command.id}
command={command}
onRun={() => runAndClose(command)}
key={entry.key}
entry={entry}
showHostLabel={addHosts.length > 1}
onRun={() => runAndClose(entry)}
onEdit={() => {
closeMenu()
onEditCommand(command)
onEditCommand(entry)
}}
onDelete={() => {
closeMenu()
onDeleteCommand(command)
onDeleteCommand(entry)
}}
/>
))}
</CommandList>
<div className="border-t border-border/50 p-1">
<button
type="button"
onClick={() => {
{hostOwnershipPending ? (
<TabBarQuickCommandHostLoadStatus failed={hostLoadFailed} />
) : (
<TabBarQuickCommandAddActions
hosts={addHosts}
onAdd={(hostId) => {
closeMenu()
onAddCommand()
onAddCommand(hostId)
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Play className="size-3.5" />
{translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831',
'Command'
)}
</button>
</div>
/>
)}
</Command>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -0,0 +1,13 @@
import { searchTerminalQuickCommands } from '@/lib/terminal-quick-command-search'
import type { HostedTerminalQuickCommand } from '@/hooks/use-terminal-quick-command-hosts'
export function searchHostedTerminalQuickCommands(
entries: readonly HostedTerminalQuickCommand[],
query: string
): HostedTerminalQuickCommand[] {
const entryByCommand = new Map(entries.map((entry) => [entry.command, entry]))
return searchTerminalQuickCommands(
entries.map((entry) => entry.command),
query
).flatMap((command) => entryByCommand.get(command) ?? [])
}

View File

@ -17,6 +17,7 @@ function setup(onRun = vi.fn()): {
commandListRef: createRef<HTMLDivElement>(),
commandValue: command.id,
filteredCommands: [command],
getCommandId: (item) => item.id,
onCommandValueChange: () => {},
onRun,
selectedCommand: command
@ -46,6 +47,26 @@ function keyEvent(init: {
// Why: Enter here RUNS a terminal command, so a stray Enter is not recoverable.
describe('useTabBarQuickCommandSearchInput IME Enter ownership', () => {
it('navigates hosted commands by the caller-provided composite key', () => {
const entries = [{ key: 'local\0shared' }, { key: 'runtime:build\0shared' }]
const onCommandValueChange = vi.fn()
const { result } = renderHook(() =>
useTabBarQuickCommandSearchInput({
commandListRef: createRef<HTMLDivElement>(),
commandValue: entries[0].key,
filteredCommands: entries,
getCommandId: (entry) => entry.key,
onCommandValueChange,
onRun: vi.fn(),
selectedCommand: entries[0]
})
)
result.current.onKeyDown(keyEvent({ key: 'ArrowDown', keyCode: 40 }))
expect(onCommandValueChange).toHaveBeenCalledWith(entries[1].key)
})
it('does not run the command on the bare redispatch after a confirm', () => {
const { onRun, result } = setup()
result.result.current.onCompositionStart()

View File

@ -1,25 +1,26 @@
import { useCallback, type KeyboardEvent, type RefObject } from 'react'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import type { TerminalQuickCommand } from '../../../../shared/types'
type SearchInputOptions = {
type SearchInputOptions<TCommand> = {
commandListRef: RefObject<HTMLDivElement | null>
commandValue: string
filteredCommands: readonly TerminalQuickCommand[]
filteredCommands: readonly TCommand[]
getCommandId: (command: TCommand) => string
onCommandValueChange: (commandId: string) => void
onRun: (command: TerminalQuickCommand) => void
selectedCommand: TerminalQuickCommand | null
onRun: (command: TCommand) => void
selectedCommand: TCommand | null
}
export function useTabBarQuickCommandSearchInput({
export function useTabBarQuickCommandSearchInput<TCommand>({
commandListRef,
commandValue,
filteredCommands,
getCommandId,
onCommandValueChange,
onRun,
selectedCommand
}: SearchInputOptions): {
}: SearchInputOptions<TCommand>): {
onBlur: () => void
onCompositionEnd: () => void
onCompositionStart: () => void
@ -41,12 +42,14 @@ export function useTabBarQuickCommandSearchInput({
if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && filteredCommands.length > 0) {
event.preventDefault()
event.stopPropagation()
const currentIndex = filteredCommands.findIndex((command) => command.id === commandValue)
const currentIndex = filteredCommands.findIndex(
(command) => getCommandId(command) === commandValue
)
const startIndex = Math.max(currentIndex, 0)
const direction = event.key === 'ArrowDown' ? 1 : -1
const nextIndex =
(startIndex + direction + filteredCommands.length) % filteredCommands.length
onCommandValueChange(filteredCommands[nextIndex].id)
onCommandValueChange(getCommandId(filteredCommands[nextIndex]))
requestAnimationFrame(() => {
commandListRef.current
?.querySelector('[cmdk-item][data-selected="true"]')
@ -62,6 +65,7 @@ export function useTabBarQuickCommandSearchInput({
commandListRef,
commandValue,
filteredCommands,
getCommandId,
imeEnter,
onCommandValueChange,
onRun,

View File

@ -44,11 +44,18 @@ vi.mock('./terminal-context-menu-dismiss', () => ({
function childrenText(children: React.ReactNode): string {
return React.Children.toArray(children)
.filter((child): child is string => typeof child === 'string')
.map((child) => {
if (typeof child === 'string') {
return child
}
return React.isValidElement<{ children?: React.ReactNode }>(child)
? childrenText(child.props.children)
: ''
})
.join('')
}
function renderMenu(overrides: Record<string, unknown> = {}): void {
function renderMenu(overrides: Record<string, unknown> = {}): string {
const props = {
open: true,
onOpenChange: vi.fn(),
@ -73,9 +80,12 @@ function renderMenu(overrides: Record<string, unknown> = {}): void {
isNativeChatView: false,
onToggleNativeChat: vi.fn(),
onCopyAgentSessionContext: vi.fn(),
repoQuickCommands: [],
globalQuickCommands: [],
quickCommandRepoLabel: null,
quickCommandHosts: [
{ hostId: 'local' as const, label: 'Local Linux', repoCommands: [], globalCommands: [] }
],
quickCommandHostLoadFailed: false,
quickCommandHostOwnershipPending: false,
quickCommandRepoLabel: 'Orca',
onQuickCommand: vi.fn(),
onAddQuickCommand: vi.fn(),
onToggleExpand: vi.fn(),
@ -86,7 +96,7 @@ function renderMenu(overrides: Record<string, unknown> = {}): void {
onCopyPaneId: vi.fn(),
...overrides
}
renderToStaticMarkup(React.createElement(TerminalContextMenu, props))
return renderToStaticMarkup(React.createElement(TerminalContextMenu, props))
}
describe('TerminalContextMenu', () => {
@ -150,4 +160,107 @@ describe('TerminalContextMenu', () => {
expect(shortcuts.list).toContain('Alt+Shift+D')
expect(shortcuts.list.some((shortcut) => shortcut.includes(','))).toBe(false)
})
it('labels commands and add actions by their owning host', () => {
const onAddQuickCommand = vi.fn()
const rendered = renderMenu({
onAddQuickCommand,
quickCommandHosts: [
{
hostId: 'local',
label: 'Local Mac',
repoCommands: [],
globalCommands: [
{
id: 'local-review',
label: 'Review',
command: 'review',
appendEnter: true
}
]
},
{
hostId: 'runtime:build',
label: 'Build Server',
repoCommands: [],
globalCommands: [
{
id: 'deploy',
label: 'Deploy',
command: 'deploy',
appendEnter: true
}
]
}
]
})
const markup = items.list.map((item) => childrenText(item.children)).join('\n')
expect(rendered).toContain('Local Mac')
expect(rendered).toContain('Build Server')
expect(markup.match(/Add to \{\{value0\}\}…/g)).toHaveLength(2)
const addItems = items.list.filter(
(item) => childrenText(item.children) === 'Add to {{value0}}…'
)
addItems.forEach((item) => item.onSelect?.())
expect(onAddQuickCommand.mock.calls.map(([hostId]) => hostId)).toEqual([
'local',
'runtime:build'
])
})
it('preserves repo and global grouping when only the local host is available', () => {
const rendered = renderMenu({
quickCommandHosts: [
{
hostId: 'local',
label: 'Local Mac',
repoCommands: [{ id: 'repo', label: 'Repo command', command: 'repo', appendEnter: true }],
globalCommands: [
{ id: 'global', label: 'Global command', command: 'global', appendEnter: true }
]
}
]
})
expect(rendered).toContain('Orca')
expect(rendered).toContain('Global')
expect(rendered).not.toContain('Local Mac')
})
it('passes hosted identity when running a command', () => {
const onQuickCommand = vi.fn()
const command = {
id: 'review',
label: 'Remote review',
action: 'agent-prompt' as const,
agent: 'codex' as const,
prompt: 'Review this change',
scope: { type: 'global' as const }
}
renderMenu({
onQuickCommand,
quickCommandHosts: [
{
hostId: 'runtime:build',
label: 'Build Server',
repoCommands: [],
globalCommands: [command]
}
]
})
const commandItem = items.list.find((item) => childrenText(item.children) === 'Remote review')
commandItem?.onSelect?.()
expect(onQuickCommand).toHaveBeenCalledWith(command, 'runtime:build\0review')
})
it('suppresses add actions while remote host ownership is loading', () => {
const rendered = renderMenu({ quickCommandHostOwnershipPending: true })
expect(rendered).toContain('Loading host…')
expect(rendered).not.toContain('Add Quick Command…')
})
})

View File

@ -12,8 +12,6 @@ import {
PanelsTopLeft,
PanelRightClose,
Pencil,
Play,
Plus,
SquareTerminal,
X
} from 'lucide-react'
@ -21,23 +19,20 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
import { AgentIcon } from '@/lib/agent-catalog'
import type { KeybindingOverrides } from '../../../../shared/keybindings'
import { translate } from '@/i18n/i18n'
import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut'
import { AgentSessionContinuationMenuItem } from './AgentSessionContinuationMenuItem'
import type { TerminalQuickCommandMenuHost } from '@/hooks/use-terminal-quick-command-hosts'
import { TerminalQuickCommandsSubmenu } from './TerminalQuickCommandsSubmenu'
type TerminalContextMenuProps = {
open: boolean
@ -63,11 +58,12 @@ type TerminalContextMenuProps = {
isNativeChatView: boolean
onToggleNativeChat: () => void
onCopyAgentSessionContext: () => void
repoQuickCommands: TerminalQuickCommand[]
globalQuickCommands: TerminalQuickCommand[]
quickCommandHosts: TerminalQuickCommandMenuHost[]
quickCommandHostLoadFailed: boolean
quickCommandHostOwnershipPending: boolean
quickCommandRepoLabel: string | null
onQuickCommand: (command: TerminalQuickCommand) => void
onAddQuickCommand: () => void
onQuickCommand: (command: TerminalQuickCommand, historyId: string) => void
onAddQuickCommand: (hostId: ExecutionHostId) => void
onToggleExpand: () => void
onSetTitle: () => void
onClearPaneTitle: () => void
@ -100,8 +96,9 @@ export default function TerminalContextMenu({
isNativeChatView,
onToggleNativeChat,
onCopyAgentSessionContext,
repoQuickCommands,
globalQuickCommands,
quickCommandHosts,
quickCommandHostLoadFailed,
quickCommandHostOwnershipPending,
quickCommandRepoLabel,
onQuickCommand,
onAddQuickCommand,
@ -112,8 +109,7 @@ export default function TerminalContextMenu({
onCopyTerminalId,
onCopyPaneId
}: TerminalContextMenuProps): React.JSX.Element {
// Why: Windows/Linux shortcut labels are long; context menu rows should show
// the primary binding only so alternative bindings do not force row wraps.
// Why: one primary binding prevents Windows/Linux shortcut labels from forcing row wraps.
const shortcuts = useMemo(
() => ({
copy: formatPrimaryShortcutLabel('terminal.copySelection', keybindings),
@ -129,32 +125,9 @@ export default function TerminalContextMenu({
}),
[keybindings]
)
const hasQuickCommands = repoQuickCommands.length > 0 || globalQuickCommands.length > 0
const showEqualizeShortcut = shortcuts.equalize !== 'Unassigned'
const showSetTitleShortcut = shortcuts.setTitle !== 'Unassigned'
const showClearPaneTitleShortcut = shortcuts.clearPaneTitle !== 'Unassigned'
const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => (
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
{isTerminalAgentQuickCommand(command) ? (
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={command.agent} size={14} />
</span>
) : (
<Play
className="size-3.5 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1 truncate">{command.label}</span>
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
<DropdownMenuShortcut className="shrink-0">
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
return (
<DropdownMenu
open={open}
@ -179,13 +152,11 @@ export default function TerminalContextMenu({
sideOffset={0}
align="start"
onCloseAutoFocus={(e) => {
// Prevent Radix from moving focus back to the hidden trigger;
// let xterm keep focus naturally.
// Keep xterm focused instead of Radix's hidden trigger.
e.preventDefault()
}}
onFocusOutside={(e) => {
// xterm reclaims focus after the contextmenu event; don't let
// Radix treat that as a dismiss signal.
// xterm reclaiming focus after contextmenu is not an outside dismissal.
e.preventDefault()
}}
onPointerDownOutside={(e) => {
@ -209,65 +180,15 @@ export default function TerminalContextMenu({
{translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')}
<DropdownMenuShortcut>{shortcuts.paste}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Play fill="currentColor" strokeWidth={0} />
{translate(
'auto.components.terminal.pane.TerminalContextMenu.ec85df5914',
'Quick Commands'
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
{hasQuickCommands ? (
<>
{quickCommandRepoLabel && repoQuickCommands.length > 0 ? (
<>
<DropdownMenuLabel className="truncate">
{quickCommandRepoLabel}
</DropdownMenuLabel>
{repoQuickCommands.map(renderQuickCommandItem)}
</>
) : null}
{globalQuickCommands.length > 0 ? (
<>
{repoQuickCommands.length > 0 ? <DropdownMenuSeparator /> : null}
{repoQuickCommands.length > 0 ? (
<DropdownMenuLabel>
{translate(
'auto.components.terminal.pane.TerminalContextMenu.3ce594a4a0',
'Global'
)}
</DropdownMenuLabel>
) : null}
{globalQuickCommands.map(renderQuickCommandItem)}
</>
) : null}
</>
) : (
<DropdownMenuItem disabled className="text-muted-foreground">
{translate(
'auto.components.terminal.pane.TerminalContextMenu.9528a65ef8',
'No quick commands'
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
// Why: the dropdown sits above dialogs; force-close before
// opening the add modal even during the open-gesture guard.
onOpenChange(false)
onAddQuickCommand()
}}
>
<Plus />
{translate(
'auto.components.terminal.pane.TerminalContextMenu.0a82b0608c',
'Add Quick Command…'
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<TerminalQuickCommandsSubmenu
hosts={quickCommandHosts}
hostLoadFailed={quickCommandHostLoadFailed}
hostOwnershipPending={quickCommandHostOwnershipPending}
repoLabel={quickCommandRepoLabel}
onRun={onQuickCommand}
onClose={() => onOpenChange(false)}
onAdd={onAddQuickCommand}
/>
{canContinueAgentSessionInNewSession ? (
<AgentSessionContinuationMenuItem onSelect={onContinueAgentSessionInNewSession} />
) : null}

View File

@ -156,8 +156,14 @@ import {
type RemotePaneLayoutPusher
} from './remote-pane-layout-push'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
import {
getRepoExecutionHostId,
isRuntimeOwnedSshTargetId,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import { useRepoById } from '@/store/selectors'
import { refitAndRefreshAllTerminalPanes } from '@/lib/pane-manager/pane-manager-registry'
import {
getTerminalQuickCommandScope,
@ -226,7 +232,6 @@ import {
getCachedUnifiedTerminalTabForWorktree
} from './terminal-unified-tab-lookup'
import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent'
import { useRepoById } from '@/store/selectors'
import {
isXtermHelperTextarea,
releaseTerminalFocusForOutsidePointerDown,
@ -234,6 +239,7 @@ import {
resyncTerminalFocusForWindowFocus,
setRegularTerminalInputFocusAttribute
} from './regular-terminal-focus-ownership'
import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts'
type TerminalPaneProps = {
tabId: string
@ -256,23 +262,28 @@ export type TerminalPaneHandle = {
type TerminalQuickCommandEditorDialogProps = {
command: TerminalQuickCommand
hostId: ExecutionHostId
onOpenChange: (open: boolean) => void
onSave: (command: TerminalQuickCommand) => void
}
function TerminalQuickCommandEditorDialog({
command,
hostId,
onOpenChange,
onSave
}: TerminalQuickCommandEditorDialogProps): React.JSX.Element {
const repos = useAppStore((store) => store.repos)
const hostRepos = hostId.startsWith('runtime:')
? repos.filter((repo) => getRepoExecutionHostId(repo) === hostId)
: repos
return (
<TerminalQuickCommandDialog
open
mode="add"
command={command}
repos={repos}
repos={hostRepos}
onOpenChange={onOpenChange}
onSave={onSave}
/>
@ -381,6 +392,8 @@ function TerminalPane(
copyKind: CloseTerminalDialogCopyKind
} | null>(null)
const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false)
const [quickCommandEditorHostId, setQuickCommandEditorHostId] =
useState<ExecutionHostId>(LOCAL_EXECUTION_HOST_ID)
const [chatLeafId, setChatLeafId] = useState<string | null>(null)
const onAgentExitedRef = useRef<(leafId: string) => void>(() => {})
const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState<string | null | undefined>(
@ -791,16 +804,26 @@ function TerminalPane(
: quickCommandRepoId
? 'This Repo'
: null
const validQuickCommands = (settings?.terminalQuickCommands ?? []).filter((command) =>
isTerminalQuickCommandComplete(command)
)
const repoQuickCommands = validQuickCommands.filter((command) => {
const scope = getTerminalQuickCommandScope(command)
return scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId)
const {
hosts: quickCommandHosts,
refreshRemoteHost: refreshQuickCommandRemoteHost,
remoteHostLoadFailed: quickCommandHostLoadFailed,
remoteHostPending: quickCommandHostOwnershipPending
} = useTerminalQuickCommandHosts(worktreeId)
const visibleQuickCommandHosts = quickCommandHosts.map((host) => {
const commands = host.commands.filter(isTerminalQuickCommandComplete)
return {
globalCommands: commands.filter(
(command) => getTerminalQuickCommandScope(command).type === 'global'
),
hostId: host.hostId,
label: host.label,
repoCommands: commands.filter((command) => {
const scope = getTerminalQuickCommandScope(command)
return scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId)
})
}
})
const globalQuickCommands = validQuickCommands.filter(
(command) => getTerminalQuickCommandScope(command).type === 'global'
)
const quickCommandGroupId =
useAppStore(
(s) =>
@ -809,17 +832,20 @@ function TerminalPane(
null
) ?? null
const openQuickCommandEditor = useCallback((scope: TerminalQuickCommandScope): void => {
setQuickCommandDraft(createTerminalQuickCommandDraft(scope))
setQuickCommandEditorOpen(true)
}, [])
const openQuickCommandEditor = useCallback(
(scope: TerminalQuickCommandScope, hostId: ExecutionHostId): void => {
setQuickCommandDraft(createTerminalQuickCommandDraft(scope))
setQuickCommandEditorHostId(hostId)
setQuickCommandEditorOpen(true)
},
[]
)
const saveQuickCommand = useCallback(
(command: TerminalQuickCommand): void => {
const currentCommands = useAppStore.getState().settings?.terminalQuickCommands ?? []
void updateSettings({ terminalQuickCommands: [...currentCommands, command] })
void useAppStore.getState().upsertTerminalQuickCommand(quickCommandEditorHostId, command)
},
[updateSettings]
[quickCommandEditorHostId]
)
useEffect(() => {
@ -2469,6 +2495,11 @@ function TerminalPane(
forceBracketedMultilineTextPaste,
rightClickToPaste
})
useEffect(() => {
if (contextMenu.open) {
refreshQuickCommandRemoteHost()
}
}, [contextMenu.open, refreshQuickCommandRemoteHost])
const getContextMenuLeafId = useCallback((): string | null => {
const paneId = contextMenu.menuPaneId
const manager = managerRef.current
@ -3007,14 +3038,15 @@ function TerminalPane(
isNativeChatView={contextMenuIsChatView}
onToggleNativeChat={handleContextMenuToggleNativeChat}
onCopyAgentSessionContext={() => void contextMenu.onCopyAgentSessionContext()}
repoQuickCommands={repoQuickCommands}
globalQuickCommands={globalQuickCommands}
quickCommandHosts={visibleQuickCommandHosts}
quickCommandHostLoadFailed={quickCommandHostLoadFailed}
quickCommandHostOwnershipPending={quickCommandHostOwnershipPending}
quickCommandRepoLabel={quickCommandRepoLabel}
onQuickCommand={contextMenu.onQuickCommand}
onAddQuickCommand={
onAddQuickCommand={(hostId) =>
quickCommandRepoId
? () => openQuickCommandEditor({ type: 'repo', repoId: quickCommandRepoId })
: () => openQuickCommandEditor({ type: 'global' })
? openQuickCommandEditor({ type: 'repo', repoId: quickCommandRepoId }, hostId)
: openQuickCommandEditor({ type: 'global' }, hostId)
}
onToggleExpand={contextMenu.onToggleExpand}
onSetTitle={contextMenu.onSetTitle}
@ -3027,6 +3059,7 @@ function TerminalPane(
{quickCommandEditorOpen ? (
<TerminalQuickCommandEditorDialog
command={quickCommandDraft}
hostId={quickCommandEditorHostId}
onOpenChange={setQuickCommandEditorOpen}
onSave={saveQuickCommand}
/>

View File

@ -0,0 +1,162 @@
import { Play, Plus } from 'lucide-react'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import type { TerminalQuickCommand } from '../../../../shared/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import { AgentIcon } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
import {
getHostedTerminalQuickCommandKey,
shouldShowTerminalQuickCommandHostOwnership,
type TerminalQuickCommandMenuHost
} from '@/hooks/use-terminal-quick-command-hosts'
type TerminalQuickCommandsSubmenuProps = {
hosts: TerminalQuickCommandMenuHost[]
hostLoadFailed: boolean
hostOwnershipPending: boolean
repoLabel: string | null
onAdd: (hostId: ExecutionHostId) => void
onClose: () => void
onRun: (command: TerminalQuickCommand, historyId: string) => void
}
export function TerminalQuickCommandsSubmenu({
hosts,
hostLoadFailed,
hostOwnershipPending,
repoLabel,
onAdd,
onClose,
onRun
}: TerminalQuickCommandsSubmenuProps): React.JSX.Element {
const nonEmptyHosts = hosts.filter(
(host) => host.repoCommands.length > 0 || host.globalCommands.length > 0
)
const showHostOwnership = shouldShowTerminalQuickCommandHostOwnership(hosts)
const singleHost = hosts[0]
const renderCommand = (hostId: ExecutionHostId, command: TerminalQuickCommand) => (
<DropdownMenuItem
key={`${hostId}:${command.id}`}
onSelect={() => onRun(command, getHostedTerminalQuickCommandKey(hostId, command.id))}
>
{isTerminalAgentQuickCommand(command) ? (
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={command.agent} size={14} />
</span>
) : (
<Play
className="size-3.5 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1 truncate">{command.label}</span>
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
<DropdownMenuShortcut className="shrink-0">
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Play fill="currentColor" strokeWidth={0} />
{translate(
'auto.components.terminal.pane.TerminalContextMenu.ec85df5914',
'Quick Commands'
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
{nonEmptyHosts.length > 0 ? (
showHostOwnership ? (
nonEmptyHosts.map((host, index) => (
<div key={host.hostId}>
{index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuLabel className="truncate">{host.label}</DropdownMenuLabel>
{[...host.repoCommands, ...host.globalCommands].map((command) =>
renderCommand(host.hostId, command)
)}
</div>
))
) : singleHost ? (
<>
{repoLabel && singleHost.repoCommands.length > 0 ? (
<DropdownMenuLabel className="truncate">{repoLabel}</DropdownMenuLabel>
) : null}
{singleHost.repoCommands.map((command) => renderCommand(singleHost.hostId, command))}
{singleHost.repoCommands.length > 0 && singleHost.globalCommands.length > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate(
'auto.components.terminal.pane.TerminalContextMenu.3ce594a4a0',
'Global'
)}
</DropdownMenuLabel>
</>
) : null}
{singleHost.globalCommands.map((command) =>
renderCommand(singleHost.hostId, command)
)}
</>
) : null
) : (
<DropdownMenuItem disabled className="text-muted-foreground">
{translate(
'auto.components.terminal.pane.TerminalContextMenu.9528a65ef8',
'No quick commands'
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{hostOwnershipPending ? (
<DropdownMenuItem disabled className="text-muted-foreground">
{hostLoadFailed
? translate(
'auto.components.terminal.pane.TerminalQuickCommandsSubmenu.3ccc7981bb',
'Host unavailable'
)
: translate(
'auto.components.terminal.pane.TerminalQuickCommandsSubmenu.54f29b7c0d',
'Loading host…'
)}
</DropdownMenuItem>
) : (
hosts.map((host) => (
<DropdownMenuItem
key={host.hostId}
onSelect={() => {
// Force-close the dropdown before its add dialog mounts above it.
onClose()
onAdd(host.hostId)
}}
>
<Plus />
{hosts.length === 1
? translate(
'auto.components.terminal.pane.TerminalContextMenu.0a82b0608c',
'Add Quick Command…'
)
: translate(
'auto.components.terminal.pane.TerminalContextMenu.15dd899676',
'Add to {{value0}}…',
{ value0: host.label }
)}
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}

View File

@ -100,7 +100,7 @@ type TerminalMenuState = {
onForkAgentSession: () => Promise<void>
onContinueAgentSessionInNewSession: () => void
onCopyAgentSessionContext: () => Promise<void>
onQuickCommand: (command: TerminalQuickCommand) => void
onQuickCommand: (command: TerminalQuickCommand, historyId: string) => void
onToggleExpand: () => void
onSetTitle: () => void
onClearPaneTitle: () => void
@ -450,9 +450,9 @@ export function useTerminalPaneContextMenu({
await copyAgentSessionContextFromPane(pane)
}
const onQuickCommand = (command: TerminalQuickCommand): void => {
const onQuickCommand = (command: TerminalQuickCommand, historyId: string): void => {
if (isTerminalAgentQuickCommand(command)) {
runQuickCommandInNewTab({ command, worktreeId, groupId })
runQuickCommandInNewTab({ command, worktreeId, groupId, historyId })
return
}

View File

@ -0,0 +1,192 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { GlobalSettings } from '../../../shared/types'
import type { RuntimeTerminalQuickCommands } from '@/store/slices/terminal-quick-command-hosts'
const testState = vi.hoisted(() => ({
executionHostId: 'runtime:build' as ExecutionHostId,
loadRuntimeTerminalQuickCommands: vi.fn(async () => {}),
runtimeEnvironments: [] as { id: string; name: string }[],
runtimeStatusByEnvironmentId: new Map<string, { connectionGeneration?: number }>(),
runtimeTerminalQuickCommands: new Map<string, RuntimeTerminalQuickCommands>(),
settings: null as GlobalSettings | null
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof testState) => unknown) => selector(testState)
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () => testState.executionHostId
}))
import {
flattenTerminalQuickCommandHosts,
getTerminalQuickCommandHostOptions,
shouldShowTerminalQuickCommandHostOwnership,
useTerminalQuickCommandHosts,
type TerminalQuickCommandHost
} from './use-terminal-quick-command-hosts'
let renderedHosts: TerminalQuickCommandHost[] = []
let remoteHostLoadFailed = false
let remoteHostPending = false
function Probe(): null {
const result = useTerminalQuickCommandHosts('worktree-1')
renderedHosts = result.hosts
remoteHostLoadFailed = result.remoteHostLoadFailed
remoteHostPending = result.remoteHostPending
return null
}
describe('useTerminalQuickCommandHosts', () => {
let root: Root
beforeEach(() => {
testState.executionHostId = 'runtime:build'
testState.loadRuntimeTerminalQuickCommands.mockClear()
testState.runtimeEnvironments = [{ id: 'build', name: 'Build Server' }]
testState.runtimeStatusByEnvironmentId = new Map([['build', { connectionGeneration: 4 }]])
testState.runtimeTerminalQuickCommands = new Map()
testState.settings = getDefaultSettings('/tmp')
renderedHosts = []
remoteHostLoadFailed = false
remoteHostPending = false
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
document.body.replaceChildren()
})
it('shows ownership only when commands can come from multiple hosts', () => {
expect(shouldShowTerminalQuickCommandHostOwnership([{ id: 'local' }])).toBe(false)
expect(
shouldShowTerminalQuickCommandHostOwnership([{ id: 'local' }, { id: 'runtime:build' }])
).toBe(true)
})
it.each([
{
name: 'unsupported',
supported: false,
generation: 4,
expected: ['local'],
pending: false
},
{
name: 'stale generation',
supported: true,
generation: 3,
expected: ['local'],
pending: true
},
{
name: 'supported current generation',
supported: true,
generation: 4,
expected: ['local', 'runtime:build'],
pending: false
}
])(
'gates the remote host when it is $name',
async ({ supported, generation, expected, pending }) => {
testState.runtimeTerminalQuickCommands = new Map([
[
'build',
{
commands: [],
connectionGeneration: generation,
error: null,
loading: false,
ready: true,
supported
}
]
])
await act(async () => root.render(createElement(Probe)))
expect(renderedHosts.map((host) => host.hostId)).toEqual(expected)
expect(remoteHostPending).toBe(pending)
expect(testState.loadRuntimeTerminalQuickCommands).toHaveBeenCalledWith('build')
}
)
it('keeps mutations pending until remote capability ownership resolves', async () => {
await act(async () => root.render(createElement(Probe)))
expect(renderedHosts.map((host) => host.hostId)).toEqual(['local'])
expect(remoteHostPending).toBe(true)
})
it('distinguishes an unresolved host failure from active loading', async () => {
testState.runtimeTerminalQuickCommands = new Map([
[
'build',
{
commands: [],
connectionGeneration: 4,
error: 'offline',
loading: false,
ready: false,
supported: null
}
]
])
await act(async () => root.render(createElement(Probe)))
expect(remoteHostPending).toBe(true)
expect(remoteHostLoadFailed).toBe(true)
})
})
describe('flattenTerminalQuickCommandHosts', () => {
it('keeps identical command ids distinct by owning host', () => {
const command = {
id: 'build',
label: 'Build',
action: 'terminal-command' as const,
command: 'pnpm build',
appendEnter: true,
scope: { type: 'global' as const }
}
const entries = flattenTerminalQuickCommandHosts([
{ hostId: 'local', label: 'Local Mac', commands: [command] },
{ hostId: 'runtime:server', label: 'Build Server', commands: [command] }
])
expect(entries.map((entry) => [entry.key, entry.hostLabel])).toEqual([
['local\0build', 'Local Mac'],
['runtime:server\0build', 'Build Server']
])
})
it('reuses execution-host registry names and rename overrides', () => {
const settings = {
...getDefaultSettings('/tmp'),
hostSettingOverrides: {
local: { displayLabel: 'Studio Mac' },
'runtime:build': { displayLabel: 'Build Server' }
}
}
expect(
getTerminalQuickCommandHostOptions(settings, [{ id: 'build', name: 'Remote Mac' }])
).toEqual([
{ id: 'local', label: 'Studio Mac' },
{ id: 'runtime:build', label: 'Build Server' }
])
})
})

View File

@ -0,0 +1,164 @@
import { useCallback, useEffect, useMemo } from 'react'
import type { GlobalSettings, TerminalQuickCommand } from '../../../shared/types'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry'
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
export type TerminalQuickCommandHost = {
commands: readonly TerminalQuickCommand[]
hostId: ExecutionHostId
label: string
}
export type TerminalQuickCommandMenuHost = {
globalCommands: TerminalQuickCommand[]
hostId: ExecutionHostId
label: string
repoCommands: TerminalQuickCommand[]
}
export type HostedTerminalQuickCommand = {
command: TerminalQuickCommand
hostId: ExecutionHostId
hostLabel: string
key: string
}
export function getHostedTerminalQuickCommandKey(
hostId: ExecutionHostId,
commandId: string
): string {
return `${hostId}\0${commandId}`
}
export function shouldShowTerminalQuickCommandHostOwnership(hosts: readonly unknown[]): boolean {
return hosts.length > 1
}
export function flattenTerminalQuickCommandHosts(
hosts: readonly TerminalQuickCommandHost[]
): HostedTerminalQuickCommand[] {
return hosts.flatMap((host) =>
host.commands.map((command) => ({
command,
hostId: host.hostId,
hostLabel: host.label,
key: getHostedTerminalQuickCommandKey(host.hostId, command.id)
}))
)
}
export function getTerminalQuickCommandHostOptions(
settings: GlobalSettings | null | undefined,
runtimeEnvironments: readonly Pick<PublicKnownRuntimeEnvironment, 'id' | 'name'>[]
): { id: ExecutionHostId; label: string }[] {
return buildExecutionHostRegistry({
repos: [],
settings,
hostSource: 'configured-only',
runtimeEnvironments,
hostLabelOverrides: getHostDisplayLabelOverrides(settings)
}).map((host) => ({ id: host.id, label: host.label }))
}
export function useTerminalQuickCommandHosts(worktreeId: string): {
executionHostId: ExecutionHostId
hosts: TerminalQuickCommandHost[]
refreshRemoteHost: () => void
remoteHostLoadFailed: boolean
remoteHostPending: boolean
} {
const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId))
const settings = useAppStore((state) => state.settings)
const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
const remoteState = useAppStore((state) => {
const parsed = parseExecutionHostId(executionHostId)
return parsed?.kind === 'runtime'
? state.runtimeTerminalQuickCommands.get(parsed.environmentId)
: undefined
})
const loadRemote = useAppStore((state) => state.loadRuntimeTerminalQuickCommands)
const parsedExecutionHost = parseExecutionHostId(executionHostId)
const remoteHostId = parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.id : null
const remoteEnvironmentId =
parsedExecutionHost?.kind === 'runtime' ? parsedExecutionHost.environmentId : null
const remoteConnectionGeneration = useAppStore((state) =>
remoteEnvironmentId
? (state.runtimeStatusByEnvironmentId.get(remoteEnvironmentId)?.connectionGeneration ?? 0)
: 0
)
useEffect(() => {
if (remoteEnvironmentId) {
void loadRemote(remoteEnvironmentId)
}
}, [loadRemote, remoteConnectionGeneration, remoteEnvironmentId])
const refreshRemoteHost = useCallback((): void => {
if (remoteEnvironmentId) {
void loadRemote(remoteEnvironmentId, { force: true })
}
}, [loadRemote, remoteEnvironmentId])
const remoteHostPending = Boolean(
remoteHostId &&
remoteEnvironmentId &&
(remoteState?.connectionGeneration !== remoteConnectionGeneration ||
remoteState.supported === null ||
remoteState === undefined)
)
const remoteHostLoadFailed = Boolean(
remoteHostPending &&
remoteState?.connectionGeneration === remoteConnectionGeneration &&
!remoteState.loading &&
remoteState.error
)
const hosts = useMemo(() => {
const hostOptions = getTerminalQuickCommandHostOptions(settings, runtimeEnvironments)
const result: TerminalQuickCommandHost[] = [
{
commands: settings?.terminalQuickCommands ?? [],
hostId: LOCAL_EXECUTION_HOST_ID,
label:
hostOptions.find((host) => host.id === LOCAL_EXECUTION_HOST_ID)?.label ?? 'This computer'
}
]
if (
!remoteHostId ||
!remoteEnvironmentId ||
remoteState?.supported !== true ||
remoteState.connectionGeneration !== remoteConnectionGeneration
) {
return result
}
result.push({
commands: remoteState.commands,
hostId: remoteHostId,
label: hostOptions.find((host) => host.id === remoteHostId)?.label ?? remoteEnvironmentId
})
return result
}, [
remoteConnectionGeneration,
remoteEnvironmentId,
remoteHostId,
remoteState,
runtimeEnvironments,
settings
])
return {
executionHostId,
hosts,
refreshRemoteHost,
remoteHostLoadFailed,
remoteHostPending
}
}

View File

@ -370,6 +370,15 @@
"runtimeHostUnreachable": "Can't reach Orca server",
"tryAgain": "Try again"
}
},
"terminal": {
"quick": {
"command": {
"hosts": {
"5b7d781d67": "Failed to save quick command"
}
}
}
}
}
},
@ -2810,7 +2819,8 @@
"c2f0b72b8d": "Insert",
"925f49f210": "Expand Pane",
"df766809e0": "Collapse Pane",
"cff67afad1": "Copy Context"
"cff67afad1": "Copy Context",
"15dd899676": "Add to {{value0}}…"
},
"TerminalErrorToast": {
"e4aa243f8c": "Restart daemon",
@ -2951,6 +2961,10 @@
"retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
"reconnectButton": "Reconnect"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "Host unavailable",
"54f29b7c0d": "Loading host…"
}
}
},
@ -3170,6 +3184,14 @@
},
"TerminalTabLeadingIcon": {
"7ab2964bea": "Unread agent completion"
},
"TabBarQuickCommandAddActions": {
"45a2f36d51": "Command",
"b856c833ae": "Command on {{value0}}"
},
"TabBarQuickCommandHostLoadStatus": {
"82e294f3ca": "Host unavailable",
"7c129b08ff": "Loading host…"
}
}
},
@ -6688,7 +6710,13 @@
"38d61927e6": "No quick commands saved.",
"44923dd982": "destructive",
"ec1ed99e70": "Delete",
"d1d0976320": "None"
"d1d0976320": "None",
"89f7e57fcc": "Saved on",
"d59bd333c3": "Update this Orca server to manage its quick commands.",
"f2bf411640": "Could not load commands from this host.",
"7ecfee5b8e": "Retry",
"601d6af51f": "Loading commands…",
"923ba89646": "Could not refresh commands from this host. Showing the last loaded commands."
},
"RecentTabOrderControl": {
"3b17c81ede": "Tab strip order",
@ -9579,7 +9607,9 @@
"hostOverride": "Host override",
"workspaceDirectory": "The client default is inherited until a host needs its own worktree directory.",
"providerHost": "Provider host",
"providerAccounts": "Credentials and account checks belong to the local client or selected remote server that owns the provider integration."
"providerAccounts": "Credentials and account checks belong to the local client or selected remote server that owns the provider integration.",
"hostCollectionProjectScopes": "Host collection + project scopes",
"terminalQuickCommandHostCollections": "Commands are saved on the selected Orca host, then scoped globally or to a project setup. Commands from this device also remain available in remote workspaces."
},
"RepositoryForkSyncSection": {
"defaultBranch": "default branch",

View File

@ -81,6 +81,26 @@ describe('runQuickCommandInNewTab', () => {
expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith('group-1', 'build')
})
it('records a host-qualified history id when provided', () => {
runQuickCommandInNewTab({
command: {
id: 'build',
label: 'Build',
action: 'terminal-command',
command: 'pnpm build',
appendEnter: true
},
worktreeId: 'wt-1',
groupId: 'group-1',
historyId: 'runtime:server\0build'
})
expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith(
'group-1',
'runtime:server\0build'
)
})
it('keeps single-line quick commands unchanged', () => {
runQuickCommandInNewTab({
command: {

View File

@ -11,6 +11,7 @@ import type { TerminalQuickCommand } from '../../../shared/types'
export type RunQuickCommandInNewTabArgs = {
command: TerminalQuickCommand
worktreeId: string
historyId?: string
/** Tab group the user clicked from. Keeps the spawned terminal in the
* pane the user initiated from when available. */
groupId?: string | null
@ -47,7 +48,8 @@ function resolveQuickCommandGroupId(
export function runQuickCommandInNewTab({
command,
worktreeId,
groupId
groupId,
historyId = command.id
}: RunQuickCommandInNewTabArgs): { tabId: string } | null {
const targetGroupId = groupId ?? undefined
if (isTerminalAgentQuickCommand(command)) {
@ -65,7 +67,7 @@ export function runQuickCommandInNewTab({
if (result?.tabId) {
const launchedGroupId = resolveQuickCommandGroupId(worktreeId, result.tabId, groupId)
if (launchedGroupId) {
useAppStore.getState().setRecentQuickCommandForGroup(launchedGroupId, command.id)
useAppStore.getState().setRecentQuickCommandForGroup(launchedGroupId, historyId)
}
return { tabId: result.tabId }
}
@ -113,7 +115,7 @@ export function runQuickCommandInNewTab({
const launchedGroupId = resolveQuickCommandGroupId(worktreeId, tab.id, groupId)
if (launchedGroupId) {
fresh.setRecentQuickCommandForGroup(launchedGroupId, command.id)
fresh.setRecentQuickCommandForGroup(launchedGroupId, historyId)
}
return { tabId: tab.id }

View File

@ -41,6 +41,7 @@ import { createOrcaProfilesSlice } from './slices/orca-profiles'
import { createNewIssueDraftSlice } from './slices/new-issue-draft'
import { createTaskCreationDraftsSlice } from './slices/task-creation-drafts'
import { createRemoteServerUpdatesSlice } from './slices/remote-server-updates'
import { createTerminalQuickCommandHostsSlice } from './slices/terminal-quick-command-hosts'
import { e2eConfig } from '@/lib/e2e-config'
import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-session'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
@ -95,7 +96,8 @@ export const useAppStore = create<AppState>()((...a) => {
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a),
...createTaskCreationDraftsSlice(...a),
...createRemoteServerUpdatesSlice(...a)
...createRemoteServerUpdatesSlice(...a),
...createTerminalQuickCommandHostsSlice(...a)
}
})

View File

@ -146,6 +146,7 @@ import { createOrcaProfilesSlice } from './orca-profiles'
import { createNewIssueDraftSlice } from './new-issue-draft'
import { createTaskCreationDraftsSlice } from './task-creation-drafts'
import { createRemoteServerUpdatesSlice } from './remote-server-updates'
import { createTerminalQuickCommandHostsSlice } from './terminal-quick-command-hosts'
function createTestStore() {
return create<AppState>()((...a) => ({
@ -189,7 +190,8 @@ function createTestStore() {
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a),
...createTaskCreationDraftsSlice(...a),
...createRemoteServerUpdatesSlice(...a)
...createRemoteServerUpdatesSlice(...a),
...createTerminalQuickCommandHostsSlice(...a)
}))
}

View File

@ -9,6 +9,7 @@ import {
clearRuntimeCompatibilityCacheForTests
} from '../../runtime/runtime-rpc-client'
import {
clearRuntimeEnvironmentConnectionGenerationsForTests,
createRuntimeStatusSlice,
type RuntimeStatusSlice,
getRuntimeEnvironmentConnectionGeneration
@ -74,6 +75,7 @@ function stubRuntimeEnvironmentApi({
}
beforeEach(() => {
clearRuntimeEnvironmentConnectionGenerationsForTests()
vi.mocked(toast.warning).mockReset()
vi.mocked(toast.dismiss).mockReset()
})
@ -379,6 +381,26 @@ describe('runtime-status slice', () => {
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe(2)
})
it('keeps stored and canonical generations aligned after same-id re-pairing', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironments([makeEnvironment({ pairingRevision: 1 })])
store.getState().setRuntimeEnvironmentStatus('env-a', {
status: makeStatus({ runtimeId: 'runtime-a' }),
checkedAt: 1
})
store.getState().setRuntimeEnvironments([makeEnvironment({ pairingRevision: 2 })])
store.getState().setRuntimeEnvironmentStatus('env-a', {
status: makeStatus({ runtimeId: 'runtime-b' }),
checkedAt: 2
})
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe(
getRuntimeEnvironmentConnectionGeneration('env-a')
)
expect(getRuntimeEnvironmentConnectionGeneration('env-a')).toBe(3)
})
it('invalidates provider state only when the active runtime session changes', () => {
const store = createSliceStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-a' } } as never)

View File

@ -148,6 +148,9 @@ export function getRuntimeEnvironmentConnectionGeneration(environmentId: string)
return connectionGenerationByEnvironment.get(environmentId) ?? 0
}
export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void =>
connectionGenerationByEnvironment.clear()
function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number {
const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1
connectionGenerationByEnvironment.set(environmentId, next)
@ -234,6 +237,7 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
// Optional-chained: minimal store assemblies (some unit tests) omit the
// detected-agents slice.
get().retainRuntimeDetectedAgents?.(environments.map((environment) => environment.id))
get().retainRuntimeTerminalQuickCommands?.(environments.map((environment) => environment.id))
// A detached environment's mirrored SSH state must not outlive it.
get().retainEnvironmentSshState?.(environments.map((environment) => environment.id))
for (const id of replacedEnvironmentIds) {
@ -263,17 +267,17 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
status.status !== null &&
(previous?.status == null || previous.status.runtimeId !== status.status.runtimeId)
const activeEnvironmentId = s.settings?.activeRuntimeEnvironmentId?.trim()
if (connectionChanged) {
advanceRuntimeEnvironmentConnectionGeneration(environmentId)
}
const connectionGeneration = connectionChanged
? advanceRuntimeEnvironmentConnectionGeneration(environmentId)
: (previous?.connectionGeneration ??
status.connectionGeneration ??
getRuntimeEnvironmentConnectionGeneration(environmentId))
if (activeEnvironmentId === environmentId && (sessionEnded || connectionChanged)) {
bumpProviderRuntimeSessionGeneration()
}
next.set(environmentId, {
...status,
connectionGeneration: connectionChanged
? (previous?.connectionGeneration ?? 0) + 1
: (previous?.connectionGeneration ?? status.connectionGeneration ?? 0)
connectionGeneration
})
return { runtimeStatusByEnvironmentId: next }
})

View File

@ -49,6 +49,7 @@ import { createOrcaProfilesSlice } from './orca-profiles'
import { createNewIssueDraftSlice } from './new-issue-draft'
import { createTaskCreationDraftsSlice } from './task-creation-drafts'
import { createRemoteServerUpdatesSlice } from './remote-server-updates'
import { createTerminalQuickCommandHostsSlice } from './terminal-quick-command-hosts'
import { translate } from '@/i18n/i18n'
export const TEST_REPO = {
@ -101,7 +102,8 @@ export function createTestStore() {
...createOrcaProfilesSlice(...a),
...createNewIssueDraftSlice(...a),
...createTaskCreationDraftsSlice(...a),
...createRemoteServerUpdatesSlice(...a)
...createRemoteServerUpdatesSlice(...a),
...createTerminalQuickCommandHostsSlice(...a)
}))
}

View File

@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { getDefaultSettings } from '../../../../shared/constants'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION,
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
const savedCommand: TerminalQuickCommand = {
id: 'remote-build',
label: 'Remote build',
action: 'terminal-command',
command: 'pnpm build',
appendEnter: true,
scope: { type: 'global' }
}
function success(result: unknown) {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function installRuntime(supported = true): ReturnType<typeof vi.fn> {
const call = vi.fn(({ method, params }: { method: string; params?: unknown }) => {
if (method === 'status.get') {
return Promise.resolve(
success({
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
capabilities: supported ? [TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY] : []
})
)
}
if (method === 'settings.getTerminalQuickCommands') {
return Promise.resolve(success({ terminalQuickCommands: [savedCommand] }))
}
if (method === 'settings.updateTerminalQuickCommands') {
const mutation = (params as { mutation: { command?: TerminalQuickCommand } }).mutation
return Promise.resolve(
success({ terminalQuickCommands: mutation.command ? [mutation.command] : [] })
)
}
throw new Error(`Unexpected method: ${method}`)
})
vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } })
return call
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
vi.clearAllMocks()
})
describe('terminal quick command host collections', () => {
it('loads a capability-gated remote host collection', async () => {
const call = installRuntime()
const store = createTestStore()
await store.getState().loadRuntimeTerminalQuickCommands('env-1')
expect(store.getState().runtimeTerminalQuickCommands.get('env-1')).toMatchObject({
commands: [savedCommand],
ready: true,
supported: true
})
expect(call.mock.calls.map(([args]) => args.method)).toEqual([
'status.get',
'settings.getTerminalQuickCommands'
])
})
it('degrades to local-only behavior for an older remote host', async () => {
const call = installRuntime(false)
const store = createTestStore()
await store.getState().loadRuntimeTerminalQuickCommands('env-old')
expect(store.getState().runtimeTerminalQuickCommands.get('env-old')).toMatchObject({
commands: [],
ready: true,
supported: false
})
expect(call).toHaveBeenCalledTimes(1)
})
it('persists one atomic mutation to the owning remote host', async () => {
const call = installRuntime()
const store = createTestStore()
await store.getState().loadRuntimeTerminalQuickCommands('env-1')
const edited = { ...savedCommand, command: 'pnpm test' }
await store.getState().upsertTerminalQuickCommand('runtime:env-1', edited)
expect(store.getState().runtimeTerminalQuickCommands.get('env-1')?.commands).toEqual([edited])
expect(call).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'settings.updateTerminalQuickCommands',
params: { mutation: { type: 'upsert', command: edited } }
})
)
})
it('does not let an older load overwrite a concurrent mutation', async () => {
let resolveLoad: (value: ReturnType<typeof success>) => void = () => undefined
const staleLoad = new Promise<ReturnType<typeof success>>((resolve) => {
resolveLoad = resolve
})
const edited = { ...savedCommand, command: 'pnpm test' }
const call = vi.fn(({ method, params }: { method: string; params?: unknown }) => {
if (method === 'status.get') {
return Promise.resolve(
success({
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
capabilities: [TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY]
})
)
}
if (method === 'settings.getTerminalQuickCommands') {
return staleLoad
}
if (method === 'settings.updateTerminalQuickCommands') {
return Promise.resolve(success({ terminalQuickCommands: [edited] }))
}
throw new Error(`Unexpected method: ${method} ${String(params)}`)
})
vi.stubGlobal('window', { api: { runtimeEnvironments: { call } } })
const store = createTestStore()
const load = store.getState().loadRuntimeTerminalQuickCommands('env-race', { force: true })
await vi.waitFor(() =>
expect(call).toHaveBeenCalledWith(
expect.objectContaining({ method: 'settings.getTerminalQuickCommands' })
)
)
await store.getState().upsertTerminalQuickCommand('runtime:env-race', edited)
resolveLoad(success({ terminalQuickCommands: [savedCommand] }))
await load
expect(store.getState().runtimeTerminalQuickCommands.get('env-race')?.commands).toEqual([
edited
])
})
it('drops cached commands before revalidating a new connection', async () => {
installRuntime()
const store = createTestStore()
await store.getState().loadRuntimeTerminalQuickCommands('env-1')
store.getState().clearRuntimeEnvironmentStatus('env-1')
const reload = store.getState().loadRuntimeTerminalQuickCommands('env-1')
expect(store.getState().runtimeTerminalQuickCommands.get('env-1')).toMatchObject({
commands: [],
ready: false,
supported: null
})
await reload
})
it('keeps Local Mac commands in the controlling client settings', async () => {
const set = vi.fn(async (updates: { terminalQuickCommands: TerminalQuickCommand[] }) => updates)
vi.stubGlobal('window', { api: { settings: { set } } })
const store = createTestStore()
store.setState({ settings: { ...getDefaultSettings('/tmp'), terminalQuickCommands: [] } })
await store.getState().upsertTerminalQuickCommand('local', savedCommand)
expect(set).toHaveBeenCalledWith({ terminalQuickCommands: [savedCommand] })
expect(store.getState().settings?.terminalQuickCommands).toEqual([savedCommand])
})
})

View File

@ -0,0 +1,285 @@
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type { TerminalQuickCommand } from '../../../../shared/types'
import {
applyTerminalQuickCommandMutation,
parseNormalizedTerminalQuickCommands,
type TerminalQuickCommandMutation
} from '../../../../shared/terminal-quick-commands'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client'
import { translate } from '@/i18n/i18n'
import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status'
export type RuntimeTerminalQuickCommands = {
commands: TerminalQuickCommand[]
connectionGeneration: number
error: string | null
loading: boolean
ready: boolean
supported: boolean | null
}
export type TerminalQuickCommandHostsSlice = {
runtimeTerminalQuickCommands: Map<string, RuntimeTerminalQuickCommands>
loadRuntimeTerminalQuickCommands: (
environmentId: string,
options?: { force?: boolean }
) => Promise<void>
upsertTerminalQuickCommand: (
hostId: ExecutionHostId,
command: TerminalQuickCommand
) => Promise<boolean>
deleteTerminalQuickCommand: (hostId: ExecutionHostId, commandId: string) => Promise<boolean>
retainRuntimeTerminalQuickCommands: (environmentIds: Iterable<string>) => void
}
const mutationChains = new Map<string, Promise<void>>()
const mutationRevisions = new Map<string, number>()
const loadRequests = new Map<string, { connectionGeneration: number; request: Promise<void> }>()
function readCommands(result: unknown): TerminalQuickCommand[] {
const raw = (result as { terminalQuickCommands?: unknown } | null)?.terminalQuickCommands
const commands = parseNormalizedTerminalQuickCommands(raw)
if (!commands) {
throw new Error('Remote Orca returned invalid quick commands.')
}
return commands
}
function updateEntry(
set: Parameters<StateCreator<AppState>>[0],
environmentId: string,
update: (current: RuntimeTerminalQuickCommands | undefined) => RuntimeTerminalQuickCommands
): void {
set((state) => {
const next = new Map(state.runtimeTerminalQuickCommands)
next.set(environmentId, update(next.get(environmentId)))
return { runtimeTerminalQuickCommands: next }
})
}
async function mutateLocalCommands(
get: Parameters<StateCreator<AppState>>[1],
mutation: TerminalQuickCommandMutation
): Promise<boolean> {
try {
const current = get().settings?.terminalQuickCommands ?? []
const next = applyTerminalQuickCommandMutation(current, mutation)
await get().updateSettingsOrThrow({ terminalQuickCommands: next })
return true
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save quick command.'
toast.error(
translate(
'auto.store.slices.terminal.quick.command.hosts.5b7d781d67',
'Failed to save quick command'
),
{ description: message }
)
return false
}
}
async function mutateRemoteCommands(
set: Parameters<StateCreator<AppState>>[0],
environmentId: string,
mutation: TerminalQuickCommandMutation
): Promise<boolean> {
mutationRevisions.set(environmentId, (mutationRevisions.get(environmentId) ?? 0) + 1)
const previous = mutationChains.get(environmentId) ?? Promise.resolve()
let succeeded = false
const request = previous.then(async () => {
const connectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId)
try {
const result = await callRuntimeRpc<{ terminalQuickCommands: unknown }>(
{ kind: 'environment', environmentId },
'settings.updateTerminalQuickCommands',
{ mutation },
{ timeoutMs: 15_000 }
)
const commands = readCommands(result)
if (getRuntimeEnvironmentConnectionGeneration(environmentId) !== connectionGeneration) {
return
}
updateEntry(set, environmentId, (current) => ({
...current,
commands,
connectionGeneration,
error: null,
loading: false,
ready: true,
supported: true
}))
succeeded = true
} catch (error) {
if (getRuntimeEnvironmentConnectionGeneration(environmentId) !== connectionGeneration) {
return
}
const message = error instanceof Error ? error.message : 'Failed to save quick command.'
updateEntry(set, environmentId, (current) => ({
commands: current?.commands ?? [],
connectionGeneration,
error: message,
loading: false,
ready: current?.ready ?? false,
supported: current?.supported ?? null
}))
toast.error(
translate(
'auto.store.slices.terminal.quick.command.hosts.5b7d781d67',
'Failed to save quick command'
),
{ description: message }
)
}
})
mutationChains.set(environmentId, request)
await request
if (mutationChains.get(environmentId) === request) {
mutationChains.delete(environmentId)
}
return succeeded
}
export const createTerminalQuickCommandHostsSlice: StateCreator<
AppState,
[],
[],
TerminalQuickCommandHostsSlice
> = (set, get) => ({
runtimeTerminalQuickCommands: new Map(),
loadRuntimeTerminalQuickCommands: async (environmentId, options) => {
const trimmed = environmentId.trim()
if (!trimmed) {
return
}
const connectionGeneration = getRuntimeEnvironmentConnectionGeneration(trimmed)
const current = get().runtimeTerminalQuickCommands.get(trimmed)
if (
!options?.force &&
current?.ready &&
current.connectionGeneration === connectionGeneration
) {
return
}
const existing = loadRequests.get(trimmed)
if (existing?.connectionGeneration === connectionGeneration) {
return existing.request
}
const request = (async () => {
updateEntry(set, trimmed, (entry) => ({
commands:
entry?.connectionGeneration === connectionGeneration ? (entry.commands ?? []) : [],
connectionGeneration,
error: null,
loading: true,
ready:
entry?.connectionGeneration === connectionGeneration ? (entry.ready ?? false) : false,
supported:
entry?.connectionGeneration === connectionGeneration ? (entry.supported ?? null) : null
}))
try {
const supported = await runtimeEnvironmentSupportsCapability(
trimmed,
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
15_000
)
if (getRuntimeEnvironmentConnectionGeneration(trimmed) !== connectionGeneration) {
return
}
if (!supported) {
updateEntry(set, trimmed, () => ({
commands: [],
connectionGeneration,
error: null,
loading: false,
ready: true,
supported: false
}))
return
}
await (mutationChains.get(trimmed) ?? Promise.resolve())
const mutationRevision = mutationRevisions.get(trimmed) ?? 0
const result = await callRuntimeRpc<{ terminalQuickCommands: unknown }>(
{ kind: 'environment', environmentId: trimmed },
'settings.getTerminalQuickCommands',
undefined,
{ timeoutMs: 15_000 }
)
if (
getRuntimeEnvironmentConnectionGeneration(trimmed) !== connectionGeneration ||
(mutationRevisions.get(trimmed) ?? 0) !== mutationRevision
) {
return
}
updateEntry(set, trimmed, () => ({
commands: readCommands(result),
connectionGeneration,
error: null,
loading: false,
ready: true,
supported: true
}))
} catch (error) {
if (getRuntimeEnvironmentConnectionGeneration(trimmed) !== connectionGeneration) {
return
}
updateEntry(set, trimmed, (entry) => ({
commands: entry?.commands ?? [],
connectionGeneration,
error: error instanceof Error ? error.message : 'Failed to load quick commands.',
loading: false,
ready: entry?.ready ?? false,
supported: entry?.supported ?? null
}))
}
})()
const trackedRequest = { connectionGeneration, request }
loadRequests.set(trimmed, trackedRequest)
try {
await request
} finally {
if (loadRequests.get(trimmed) === trackedRequest) {
loadRequests.delete(trimmed)
}
}
},
upsertTerminalQuickCommand: async (hostId, command) => {
const parsed = parseExecutionHostId(hostId)
if (!parsed || parsed.kind !== 'runtime') {
return mutateLocalCommands(get, { type: 'upsert', command })
}
return mutateRemoteCommands(set, parsed.environmentId, { type: 'upsert', command })
},
deleteTerminalQuickCommand: async (hostId, commandId) => {
const parsed = parseExecutionHostId(hostId)
if (!parsed || parsed.kind !== 'runtime') {
return mutateLocalCommands(get, { type: 'delete', id: commandId })
}
return mutateRemoteCommands(set, parsed.environmentId, { type: 'delete', id: commandId })
},
retainRuntimeTerminalQuickCommands: (environmentIds) => {
const keep = new Set(environmentIds)
set((state) => {
const next = new Map(state.runtimeTerminalQuickCommands)
let changed = false
for (const id of next.keys()) {
if (!keep.has(id)) {
next.delete(id)
mutationChains.delete(id)
mutationRevisions.delete(id)
loadRequests.delete(id)
changed = true
}
}
return changed ? { runtimeTerminalQuickCommands: next } : state
})
}
})

View File

@ -39,6 +39,7 @@ import type { OrcaProfilesSlice } from './slices/orca-profiles'
import type { NewIssueDraftSlice } from './slices/new-issue-draft'
import type { TaskCreationDraftsSlice } from './slices/task-creation-drafts'
import type { RemoteServerUpdatesSlice } from './slices/remote-server-updates'
import type { TerminalQuickCommandHostsSlice } from './slices/terminal-quick-command-hosts'
export type AppState = RepoSlice &
SparsePresetsSlice &
@ -80,4 +81,5 @@ export type AppState = RepoSlice &
OrcaProfilesSlice &
NewIssueDraftSlice &
TaskCreationDraftsSlice &
RemoteServerUpdatesSlice
RemoteServerUpdatesSlice &
TerminalQuickCommandHostsSlice