diff --git a/src/renderer/src/components/settings/QuickCommandsPane.test.ts b/src/renderer/src/components/settings/QuickCommandsPane.test.ts index ac0689d92..877134ffc 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.test.ts +++ b/src/renderer/src/components/settings/QuickCommandsPane.test.ts @@ -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 + ) + }) +}) diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index 526255160..f7f703e7a 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -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) => 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 +): 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(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(null) const consumedAddIntentSignalRef = useRef(0) @@ -58,11 +131,35 @@ export function QuickCommandsPane({ const [scopeSelection, setScopeSelection] = useState | 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([GLOBAL_SCOPE_KEY, ...repos.map((r) => r.id)]), - [repos] + () => new Set([GLOBAL_SCOPE_KEY, ...hostRepos.map((repo) => repo.id)]), + [hostRepos] ) const effectiveSelection: ReadonlySet = 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 => { @@ -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({ -

{ownership.description}

+

+ {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.' + )} +

+ {shouldShowTerminalQuickCommandHostOwnership(hostOptions) ? ( +
+ + +
+ ) : null} + - setEditor({ mode: 'edit', command })} - onRemove={(command) => void removeCommand(command)} - /> + {selectedRuntimeCommandsAreCurrent && selectedRuntimeCommands?.supported === false ? ( +
+ {translate( + 'auto.components.settings.QuickCommandsPane.d59bd333c3', + 'Update this Orca server to manage its quick commands.' + )} +
+ ) : selectedRuntimeCommandsAreCurrent && + selectedRuntimeCommands?.error && + !selectedRuntimeCommands.ready ? ( +
+ + {translate( + 'auto.components.settings.QuickCommandsPane.f2bf411640', + 'Could not load commands from this host.' + )} + + +
+ ) : selectedEnvironmentId && + (!selectedRuntimeCommandsAreCurrent || + (selectedRuntimeCommands?.loading && !selectedRuntimeCommands.ready)) ? ( +
+ {translate('auto.components.settings.QuickCommandsPane.601d6af51f', 'Loading commands…')} +
+ ) : ( + <> + {shouldShowQuickCommandsRefreshError( + selectedRuntimeCommandsAreCurrent, + selectedRuntimeCommands + ) ? ( +
+ + {translate( + 'auto.components.settings.QuickCommandsPane.923ba89646', + 'Could not refresh commands from this host. Showing the last loaded commands.' + )} + + +
+ ) : null} + + setEditor({ + mode: 'edit', + command, + connectionGeneration: selectedRuntimeConnectionGeneration, + hostId: selectedHostId + }) + } + onRemove={(command) => void removeCommand(command)} + /> + + )} {editor !== null ? ( !open && setEditor(null)} onSave={saveCommand} /> diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 259bbb3b3..118d4938e 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1546,7 +1546,6 @@ function Settings(): React.JSX.Element { {isSectionMounted('quick-commands') ? ( ) : null} diff --git a/src/renderer/src/components/settings/setting-ownership.test.ts b/src/renderer/src/components/settings/setting-ownership.test.ts index 49439d414..a13985132 100644 --- a/src/renderer/src/components/settings/setting-ownership.test.ts +++ b/src/renderer/src/components/settings/setting-ownership.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/components/settings/setting-ownership.ts b/src/renderer/src/components/settings/setting-ownership.ts index 71f8be606..466499164 100644 --- a/src/renderer/src/components/settings/setting-ownership.ts +++ b/src/renderer/src/components/settings/setting-ownership.ts @@ -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 { ) }, 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: { diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandAddActions.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandAddActions.tsx new file mode 100644 index 000000000..e45ed43c1 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandAddActions.tsx @@ -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 ( +
+ {hosts.map((host) => ( + + ))} +
+ ) +} diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandHostLoadStatus.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandHostLoadStatus.tsx new file mode 100644 index 000000000..bee464bb8 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandHostLoadStatus.tsx @@ -0,0 +1,21 @@ +import { translate } from '@/i18n/i18n' + +export function TabBarQuickCommandHostLoadStatus({ + failed +}: { + failed: boolean +}): React.JSX.Element { + return ( +
+ {failed + ? translate( + 'auto.components.tab.bar.TabBarQuickCommandHostLoadStatus.82e294f3ca', + 'Host unavailable' + ) + : translate( + 'auto.components.tab.bar.TabBarQuickCommandHostLoadStatus.7c129b08ff', + 'Loading host…' + )} +
+ ) +} diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx index 996c89fb7..269506ba2 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx @@ -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 ( @@ -36,7 +39,14 @@ export function TabBarQuickCommandItem({ /> )} - {command.label} + + + {command.label} + + {showHostLabel ? ( + {entry.hostLabel} + ) : null} + {isTerminalAgentQuickCommand(command) ? `${getAgentLabel(command.agent)}: ${command.prompt}` diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index d972edda0..bbbdddabc 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -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 => { + const handleDeleteCommand = async (entry: HostedTerminalQuickCommand): Promise => { + 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 ( <> {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({ )} ) : null} - {filteredRepoCommands.map((command) => ( + {filteredRepoCommands.map((entry) => ( 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 ? ( ) : null} - {filteredGlobalCommands.map((command) => ( + {filteredGlobalCommands.map((entry) => ( 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) }} /> ))} -
- -
+ /> + )} diff --git a/src/renderer/src/components/tab-bar/hosted-terminal-quick-command-search.ts b/src/renderer/src/components/tab-bar/hosted-terminal-quick-command-search.ts new file mode 100644 index 000000000..32d25d16f --- /dev/null +++ b/src/renderer/src/components/tab-bar/hosted-terminal-quick-command-search.ts @@ -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) ?? []) +} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts index 07ce4c370..df75eeae4 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts @@ -17,6 +17,7 @@ function setup(onRun = vi.fn()): { commandListRef: createRef(), 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(), + 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() diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts index 6d27dc40c..cd10dab21 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts @@ -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 = { commandListRef: RefObject 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({ commandListRef, commandValue, filteredCommands, + getCommandId, onCommandValueChange, onRun, selectedCommand -}: SearchInputOptions): { +}: SearchInputOptions): { 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, diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 33672553d..230c2d7d8 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -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 = {}): void { +function renderMenu(overrides: Record = {}): string { const props = { open: true, onOpenChange: vi.fn(), @@ -73,9 +80,12 @@ function renderMenu(overrides: Record = {}): 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 = {}): 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…') + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 353887352..01740d338 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -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 => ( - onQuickCommand(command)}> - {isTerminalAgentQuickCommand(command) ? ( - - - - ) : ( - - )} - {command.label} - {!isTerminalAgentQuickCommand(command) && !command.appendEnter ? ( - - {translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')} - - ) : null} - - ) - return ( { - // 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')} {shortcuts.paste} - - - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.ec85df5914', - 'Quick Commands' - )} - - - {hasQuickCommands ? ( - <> - {quickCommandRepoLabel && repoQuickCommands.length > 0 ? ( - <> - - {quickCommandRepoLabel} - - {repoQuickCommands.map(renderQuickCommandItem)} - - ) : null} - {globalQuickCommands.length > 0 ? ( - <> - {repoQuickCommands.length > 0 ? : null} - {repoQuickCommands.length > 0 ? ( - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.3ce594a4a0', - 'Global' - )} - - ) : null} - {globalQuickCommands.map(renderQuickCommandItem)} - - ) : null} - - ) : ( - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.9528a65ef8', - 'No quick commands' - )} - - )} - - { - // Why: the dropdown sits above dialogs; force-close before - // opening the add modal even during the open-gesture guard. - onOpenChange(false) - onAddQuickCommand() - }} - > - - {translate( - 'auto.components.terminal.pane.TerminalContextMenu.0a82b0608c', - 'Add Quick Command…' - )} - - - + onOpenChange(false)} + onAdd={onAddQuickCommand} + /> {canContinueAgentSessionInNewSession ? ( ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8be3b9509..09002b5e2 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 ( @@ -381,6 +392,8 @@ function TerminalPane( copyKind: CloseTerminalDialogCopyKind } | null>(null) const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false) + const [quickCommandEditorHostId, setQuickCommandEditorHostId] = + useState(LOCAL_EXECUTION_HOST_ID) const [chatLeafId, setChatLeafId] = useState(null) const onAgentExitedRef = useRef<(leafId: string) => void>(() => {}) const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState( @@ -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 ? ( diff --git a/src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx b/src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx new file mode 100644 index 000000000..ed650dc14 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalQuickCommandsSubmenu.tsx @@ -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) => ( + onRun(command, getHostedTerminalQuickCommandKey(hostId, command.id))} + > + {isTerminalAgentQuickCommand(command) ? ( + + + + ) : ( + + )} + {command.label} + {!isTerminalAgentQuickCommand(command) && !command.appendEnter ? ( + + {translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')} + + ) : null} + + ) + + return ( + + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.ec85df5914', + 'Quick Commands' + )} + + + {nonEmptyHosts.length > 0 ? ( + showHostOwnership ? ( + nonEmptyHosts.map((host, index) => ( +
+ {index > 0 ? : null} + {host.label} + {[...host.repoCommands, ...host.globalCommands].map((command) => + renderCommand(host.hostId, command) + )} +
+ )) + ) : singleHost ? ( + <> + {repoLabel && singleHost.repoCommands.length > 0 ? ( + {repoLabel} + ) : null} + {singleHost.repoCommands.map((command) => renderCommand(singleHost.hostId, command))} + {singleHost.repoCommands.length > 0 && singleHost.globalCommands.length > 0 ? ( + <> + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.3ce594a4a0', + 'Global' + )} + + + ) : null} + {singleHost.globalCommands.map((command) => + renderCommand(singleHost.hostId, command) + )} + + ) : null + ) : ( + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.9528a65ef8', + 'No quick commands' + )} + + )} + + {hostOwnershipPending ? ( + + {hostLoadFailed + ? translate( + 'auto.components.terminal.pane.TerminalQuickCommandsSubmenu.3ccc7981bb', + 'Host unavailable' + ) + : translate( + 'auto.components.terminal.pane.TerminalQuickCommandsSubmenu.54f29b7c0d', + 'Loading host…' + )} + + ) : ( + hosts.map((host) => ( + { + // Force-close the dropdown before its add dialog mounts above it. + onClose() + onAdd(host.hostId) + }} + > + + {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 } + )} + + )) + )} +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index a87291fb7..207de0eaf 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -100,7 +100,7 @@ type TerminalMenuState = { onForkAgentSession: () => Promise onContinueAgentSessionInNewSession: () => void onCopyAgentSessionContext: () => Promise - 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 } diff --git a/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts b/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts new file mode 100644 index 000000000..082683dde --- /dev/null +++ b/src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts @@ -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(), + runtimeTerminalQuickCommands: new Map(), + 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' } + ]) + }) +}) diff --git a/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts b/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts new file mode 100644 index 000000000..a70e0e750 --- /dev/null +++ b/src/renderer/src/hooks/use-terminal-quick-command-hosts.ts @@ -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[] +): { 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 + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 422cfaedd..d599add61 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts index 27996f5db..0f7284a1c 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts @@ -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: { diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.ts index b0ed46232..526d35e5b 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.ts @@ -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 } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 1c6ebf327..5687dfc97 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -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()((...a) => { ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a), ...createTaskCreationDraftsSlice(...a), - ...createRemoteServerUpdatesSlice(...a) + ...createRemoteServerUpdatesSlice(...a), + ...createTerminalQuickCommandHostsSlice(...a) } }) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 1718beca9..49c0ae51b 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -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()((...a) => ({ @@ -189,7 +190,8 @@ function createTestStore() { ...createOrcaProfilesSlice(...a), ...createNewIssueDraftSlice(...a), ...createTaskCreationDraftsSlice(...a), - ...createRemoteServerUpdatesSlice(...a) + ...createRemoteServerUpdatesSlice(...a), + ...createTerminalQuickCommandHostsSlice(...a) })) } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index e68bf50e3..1462d5ea7 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -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) diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 60351672e..f1eab5721 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -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 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 ({ 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 { + 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) => void = () => undefined + const staleLoad = new Promise>((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]) + }) +}) diff --git a/src/renderer/src/store/slices/terminal-quick-command-hosts.ts b/src/renderer/src/store/slices/terminal-quick-command-hosts.ts new file mode 100644 index 000000000..df951c773 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-quick-command-hosts.ts @@ -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 + loadRuntimeTerminalQuickCommands: ( + environmentId: string, + options?: { force?: boolean } + ) => Promise + upsertTerminalQuickCommand: ( + hostId: ExecutionHostId, + command: TerminalQuickCommand + ) => Promise + deleteTerminalQuickCommand: (hostId: ExecutionHostId, commandId: string) => Promise + retainRuntimeTerminalQuickCommands: (environmentIds: Iterable) => void +} + +const mutationChains = new Map>() +const mutationRevisions = new Map() +const loadRequests = new Map }>() + +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>[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>[1], + mutation: TerminalQuickCommandMutation +): Promise { + 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>[0], + environmentId: string, + mutation: TerminalQuickCommandMutation +): Promise { + 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 + }) + } +}) diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index aae3b171c..f42325c0b 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -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