Split runtime environments pane layers (#17198)

* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Split runtime file client layers

* Split ports panel layers

* Split runtime environments pane layers

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
This commit is contained in:
Neil
2026-08-29 20:18:02 -07:00
committed by GitHub
parent e1166ec291
commit 8d3a420b7b
12 changed files with 1935 additions and 1466 deletions
-1
View File
@@ -44,7 +44,6 @@ inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
import { ChevronDown, Loader2, RefreshCw } from 'lucide-react'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import {
getActiveServerModeDescription,
getHostModelCapabilitySummary,
getRuntimeCapabilitiesSummary,
type RuntimeHostDetails
} from './runtime-environment-host-details'
type RuntimeActiveServerSectionProps = {
visible: boolean
advancedOpen: boolean
allowLocalRuntime: boolean
localRuntimeValue: string
noRuntimeValue: string
activeValue: string
environments: PublicKnownRuntimeEnvironment[]
detailsByEnvironmentId: Record<string, RuntimeHostDetails>
isBusy: boolean
isLoading: boolean
onToggleAdvanced: () => void
onValueChange: (value: string) => void
onRefresh: () => void
}
export function RuntimeActiveServerSection({
visible,
advancedOpen,
allowLocalRuntime,
localRuntimeValue,
noRuntimeValue,
activeValue,
environments,
detailsByEnvironmentId,
isBusy,
isLoading,
onToggleAdvanced,
onValueChange,
onRefresh
}: RuntimeActiveServerSectionProps): React.JSX.Element {
return (
<div data-settings-section="default-runtime" className={!visible ? 'hidden' : undefined}>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggleAdvanced}
className="-ml-2 text-xs"
aria-expanded={advancedOpen}
aria-controls="runtime-server-advanced-content"
>
{translate('auto.components.settings.RuntimeEnvironmentsPane.advanced', 'Advanced')}
<ChevronDown className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')} />
</Button>
<div
id="runtime-server-advanced-content"
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
inert={!advancedOpen}
>
<div className="min-h-0">
<div
className={cn(
'space-y-2 px-1 pt-3 pb-1 transition-[opacity,transform] duration-150 ease-out',
advancedOpen
? 'translate-y-0 opacity-100 delay-200'
: '-translate-y-1 opacity-0 delay-0'
)}
>
<div className="space-y-1">
<Label id="runtime-active-server-label">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541',
'Active Server'
)}
</Label>
<p className="text-xs text-muted-foreground">
{getActiveServerModeDescription(allowLocalRuntime)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={activeValue}
onValueChange={(value) => {
if (value !== activeValue) {
onValueChange(value)
}
}}
disabled={isBusy}
>
<SelectTrigger
size="sm"
className="min-w-[260px]"
aria-labelledby="runtime-active-server-label"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowLocalRuntime ? (
<SelectItem value={localRuntimeValue}>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.78692becbd',
'Local desktop'
)}
</SelectItem>
) : environments.length === 0 ? (
<SelectItem value={noRuntimeValue} disabled>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c',
'No server connected'
)}
</SelectItem>
) : null}
{environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003',
'Refresh servers'
)}
title={translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003',
'Refresh servers'
)}
onClick={onRefresh}
disabled={isLoading || isBusy}
>
{isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</div>
{environments.length > 0 ? (
<div className="space-y-2 pt-2">
<div className="text-xs font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverDetails',
'Server details'
)}
</div>
<div className="space-y-1 rounded-lg border border-border/50 bg-card/30 p-2">
{environments.map((environment) => {
const details = detailsByEnvironmentId[environment.id]
return (
<div
key={environment.id}
className="grid gap-1 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)]"
>
<div className="truncate font-medium text-foreground">
{environment.name}
</div>
<div className="min-w-0 space-y-0.5">
<div className="truncate font-mono">
{environment.endpoints[0]?.endpoint ??
translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da',
'No endpoint'
)}
</div>
{details?.runtimeStatus ? (
<div className="truncate">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a',
'Protocol {{value0}}',
{
value0:
details.runtimeStatus?.runtimeProtocolVersion ??
details.runtimeStatus?.protocolVersion ??
0
}
)}
{details.runtimeStatus.hostPlatform
? ` · ${details.runtimeStatus.hostPlatform}`
: ''}
{' · '}
{getRuntimeCapabilitiesSummary(details.runtimeStatus)}
</div>
) : null}
{getHostModelCapabilitySummary(details?.runtimeStatus) ? (
<div className="truncate">
{getHostModelCapabilitySummary(details?.runtimeStatus)}
</div>
) : null}
</div>
</div>
)
})}
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,140 @@
import { Loader2, Trash2 } from 'lucide-react'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
export function RuntimeEnvironmentSwitchDialog({
pendingSwitchValue,
switchingValue,
switchError,
getEnvironmentLabel,
onOpenChange,
onCancel,
onConfirm
}: {
pendingSwitchValue: string | null
switchingValue: string | null
switchError: string | null
getEnvironmentLabel: (value: string) => string
onOpenChange: (open: boolean) => void
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<Dialog open={pendingSwitchValue !== null} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.d570c35a99',
'Switch Server'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.b2290ed203',
'Orca will focus this host and load its projects. Existing terminals and browser tabs on other hosts stay alive.'
)}
</DialogDescription>
</DialogHeader>
{pendingSwitchValue ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.05e0fc3ebf',
'Switch to'
)}
</div>
<div className="mt-0.5 truncate font-medium">
{getEnvironmentLabel(pendingSwitchValue)}
</div>
</div>
) : null}
{switchError ? <p className="text-sm text-destructive">{switchError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={switchingValue !== null}>
{translate('auto.components.settings.RuntimeEnvironmentsPane.af53761f31', 'Cancel')}
</Button>
<Button onClick={onConfirm} disabled={switchingValue !== null}>
{switchingValue !== null ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.settings.RuntimeEnvironmentsPane.d2e00809e4', 'Switch')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function RuntimeEnvironmentRemoveDialog({
pendingRemove,
removingId,
removeError,
removingActiveServer,
onOpenChange,
onCancel,
onConfirm
}: {
pendingRemove: PublicKnownRuntimeEnvironment | null
removingId: string | null
removeError: string | null
removingActiveServer: boolean
onOpenChange: (open: boolean) => void
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<Dialog open={pendingRemove !== null} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.bb90dd6487',
'Remove Server'
)}
</DialogTitle>
<DialogDescription>
{removingActiveServer
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerDescription',
'Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d',
'This removes the saved server from Orca. It does not change the active server.'
)}
</DialogDescription>
</DialogHeader>
{pendingRemove ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="truncate font-medium">{pendingRemove.name}</div>
<div className="mt-0.5 truncate font-mono text-muted-foreground">
{pendingRemove.endpoints[0]?.endpoint ??
translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da',
'No endpoint'
)}
</div>
</div>
) : null}
{removeError ? <p className="text-sm text-destructive">{removeError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={removingId !== null}>
{translate('auto.components.settings.RuntimeEnvironmentsPane.af53761f31', 'Cancel')}
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={removingId !== null}>
{removingId !== null ? <Loader2 className="animate-spin" /> : <Trash2 />}
{translate('auto.components.settings.RuntimeEnvironmentsPane.d25f0688b1', 'Remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,197 @@
import { translate } from '@/i18n/i18n'
import {
describeRuntimeCompatBlock,
evaluateRuntimeCompat,
type RuntimeCompatVerdict
} from '../../../../shared/protocol-compat'
import {
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
RUNTIME_PROTOCOL_VERSION,
TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
export type RuntimeHostDetails = {
status: 'loading' | 'ready' | 'error'
runtimeStatus: RuntimeStatus | null
compatibility: RuntimeCompatVerdict | null
error: string | null
}
export function evaluateHostDetails(status: RuntimeStatus): RuntimeCompatVerdict {
return evaluateRuntimeCompat({
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
serverMinCompatibleClientProtocolVersion:
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
})
}
export function getHostDetailsSummary(details: RuntimeHostDetails | undefined): string {
if (!details || details.status === 'loading') {
return translate('auto.components.settings.RuntimeEnvironmentsPane.5120beaac6', 'Checking…')
}
if (details.status === 'error') {
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.c8791efc45',
'Status unavailable'
)
}
if (details.compatibility?.kind === 'blocked') {
return details.compatibility.reason === 'client-too-old'
? translate('auto.components.settings.RuntimeEnvironmentsPane.62ac182a27', 'Update client')
: translate('auto.components.settings.RuntimeEnvironmentsPane.86ed75bec8', 'Update server')
}
return translate('auto.components.settings.RuntimeEnvironmentsPane.9a91c4a0eb', 'Compatible')
}
export function getHostDetailsDescription(details: RuntimeHostDetails | undefined): string | null {
if (!details || details.status === 'loading') {
return null
}
if (details.status === 'error') {
return details.error
}
if (details.compatibility?.kind === 'blocked') {
return describeRuntimeCompatBlock(details.compatibility)
}
return null
}
export function getRuntimeCapabilitiesSummary(status: RuntimeStatus | null | undefined): string {
const capabilities = status?.capabilities ?? []
if (capabilities.length === 0) {
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.4b5c6d7e8f',
'No capabilities reported'
)
}
const visibleCapabilities = capabilities.slice(0, 3).join(', ')
const hiddenCount = capabilities.length - 3
return hiddenCount > 0 ? `${visibleCapabilities} +${hiddenCount}` : visibleCapabilities
}
export function getHostModelCapabilitySummary(
status: RuntimeStatus | null | undefined
): string | null {
if (!status) {
return null
}
const capabilities = status.capabilities
if (!capabilities) {
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityUnknown',
'Host model support: checking server capabilities'
)
}
const missing = [
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
].filter((capability) => !capabilities.includes(capability))
if (missing.length === 0) {
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilitySupported',
'Host model support: ready'
)
}
const missingLabels = missing.map(getHostModelCapabilityLabel)
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityMissing',
'Host model support: update server for {{value0}}',
{ value0: missingLabels.join(', ') }
)
}
function getHostModelCapabilityLabel(capability: string): string {
switch (capability) {
case PROJECT_HOST_SETUP_RUNTIME_CAPABILITY:
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityProjectSetup',
'project setup'
)
case TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY:
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityTaskSourceContext',
'task source context'
)
case WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY:
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityWorkspaceRunContext',
'workspace run context'
)
default:
return capability
}
}
export function getActiveServerModeDescription(allowLocalRuntime: boolean): string {
return allowLocalRuntime
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a',
'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8',
'Selecting a saved server makes this browser use that paired Orca runtime as its default Host.'
)
}
export function isRuntimeEnvironmentRemovalBlocked(
activeRuntimeEnvironmentId: string | null | undefined,
environmentId: string
): boolean {
return activeRuntimeEnvironmentId === environmentId
}
export type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected'
export function getRuntimeServerConnectionState(
details: RuntimeHostDetails | undefined
): RuntimeServerConnectionState {
if (!details || details.status === 'loading') {
return 'checking'
}
if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') {
return 'disconnected'
}
// Why: an attached, reachable, compatible host is "Connected" (and exposes
// Disconnect). Whether it is the default *active* server is a separate concept,
// surfaced by the Advanced > Active Server selector and the row's help text —
// it must not change this connection label, or the dot/label/button disagree.
return 'connected'
}
export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string {
switch (state) {
case 'connected':
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverConnected',
'Connected'
)
case 'checking':
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverChecking',
'Checking…'
)
case 'disconnected':
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected',
'Disconnected'
)
}
}
export function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string {
switch (state) {
case 'connected':
return 'bg-emerald-500'
case 'checking':
return 'bg-yellow-500'
case 'disconnected':
return 'bg-muted-foreground/40'
}
}
@@ -0,0 +1,2 @@
export const LOCAL_RUNTIME_VALUE = '__local__'
export const NO_RUNTIME_VALUE = '__none__'
@@ -0,0 +1,194 @@
import { AlertTriangle, Loader2, Server, ServerOff, Trash2 } from 'lucide-react'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import {
getHostDetailsDescription,
getHostDetailsSummary,
getRuntimeServerConnectionLabel,
getRuntimeServerConnectionState,
getRuntimeServerDotClass,
type RuntimeHostDetails
} from './runtime-environment-host-details'
import {
getRemoteServerManualUpdateHelp,
RemoteServerUpdateStatus
} from './RemoteServerUpdateStatus'
type RuntimeServerRowProps = {
environment: PublicKnownRuntimeEnvironment
details: RuntimeHostDetails | undefined
isActive: boolean
remoteUpdate: RemoteServerUpdateEntry | undefined
remoteServerUpdatesRunning: boolean
connecting: boolean
switching: boolean
disconnecting: boolean
removing: boolean
isBusy: boolean
onOpenUpdate: () => void
onDisconnect: (environment: PublicKnownRuntimeEnvironment) => void
onConnect: (environment: PublicKnownRuntimeEnvironment) => void
onRemove: (environment: PublicKnownRuntimeEnvironment) => void
}
export function RuntimeServerRow({
environment,
details,
isActive,
remoteUpdate,
remoteServerUpdatesRunning,
connecting,
switching,
disconnecting,
removing,
isBusy,
onOpenUpdate,
onDisconnect,
onConnect,
onRemove
}: RuntimeServerRowProps): React.JSX.Element {
const detailsDescription = getHostDetailsDescription(details)
const connectionState = getRuntimeServerConnectionState(details)
// A connected host exposes Disconnect; otherwise Connect.
const isReachable = connectionState === 'connected'
const actionBusy = connecting || switching || disconnecting || removing
return (
<div data-settings-section={environment.id} className="flex items-center gap-3 px-4 py-3">
<Server className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">{environment.name}</div>
<span
className={cn(
'size-2 shrink-0 rounded-full',
getRuntimeServerDotClass(connectionState)
)}
/>
<span className="text-[11px] text-muted-foreground">
{getRuntimeServerConnectionLabel(connectionState)}
</span>
{details?.compatibility?.kind === 'blocked' ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : details?.status === 'loading' ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{environment.connectionDependency === 'ssh-tunnel'
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired',
'SSH tunnel required'
)
: isActive
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
'Active server for server-routed projects, terminals, and provider checks.'
)
: getHostDetailsSummary(details)}
</p>
{detailsDescription ? (
<p
className={cn(
'mt-0.5 truncate text-xs',
details?.compatibility?.kind === 'blocked'
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{detailsDescription}
</p>
) : null}
{remoteUpdate ? (
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="text-[11px] text-muted-foreground">
{remoteUpdate.currentVersion
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.orcaVersion',
'Orca v{{value0}}',
{ value0: remoteUpdate.currentVersion }
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.versionUnavailable',
'Orca version unavailable'
)}
</span>
<RemoteServerUpdateStatus entry={remoteUpdate} compact />
</div>
) : null}
{remoteUpdate?.phase === 'manual' ? (
<p className="mt-1 text-xs text-muted-foreground">
{getRemoteServerManualUpdateHelp(remoteUpdate)}
</p>
) : null}
{remoteUpdate?.phase === 'failed' && remoteUpdate.error ? (
<p className="mt-1 text-xs text-destructive">{remoteUpdate.error}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{remoteUpdate?.phase === 'available' || remoteUpdate?.phase === 'failed' ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onOpenUpdate}
disabled={remoteServerUpdatesRunning}
>
{translate('auto.components.settings.RuntimeEnvironmentsPane.updateServer', 'Update')}
</Button>
) : null}
{isReachable ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => onDisconnect(environment)}
disabled={actionBusy}
>
{disconnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ServerOff className="size-3" />
)}
{translate('auto.components.settings.RuntimeEnvironmentsPane.disconnect', 'Disconnect')}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => onConnect(environment)}
disabled={actionBusy || connectionState === 'checking'}
>
{connecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Server className="size-3" />
)}
{translate('auto.components.settings.RuntimeEnvironmentsPane.connect', 'Connect')}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(environment)}
className="size-7 text-muted-foreground hover:text-red-400"
disabled={isBusy}
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2',
'Remove {{value0}}',
{ value0: environment.name }
)}
>
{removing ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,224 @@
import { ChevronDown, Share2 } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator'
export type RemoteServerWorkflow = 'connect' | 'cloud-vm' | 'share'
export function RuntimeServerWorkflowPicker({
canGeneratePairingUrl,
visibleWorkflow,
onCloseAddServerForm,
onWorkflowChange
}: {
canGeneratePairingUrl: boolean
visibleWorkflow: RemoteServerWorkflow
onCloseAddServerForm: () => void
onWorkflowChange: (workflow: RemoteServerWorkflow) => void
}): React.JSX.Element {
return (
<div
role="group"
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.workflow',
'Remote server workflow'
)}
className={cn('grid gap-2 sm:grid-cols-2', canGeneratePairingUrl && 'sm:grid-cols-3')}
>
{(
[
[
'connect',
translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow',
'Connect to a host'
),
translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp',
'This app joins another machine'
)
],
[
'share',
translate(
'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow',
'Share this host'
),
translate(
'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp',
'Other devices join this machine'
)
],
[
'cloud-vm',
translate(
'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow',
'Cloud VM'
),
translate(
'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp',
'Manage recipe-created cloud machines'
)
]
] as const
)
.filter(([value]) => value !== 'share' || canGeneratePairingUrl)
.map(([value, label, description]) => (
<button
key={value}
type="button"
aria-pressed={visibleWorkflow === value}
onClick={() => {
if (value !== 'connect') {
onCloseAddServerForm()
}
onWorkflowChange(value)
}}
className={cn(
'rounded-lg border p-3 text-left transition-colors',
visibleWorkflow === value
? 'border-ring bg-accent text-accent-foreground'
: 'border-border hover:bg-accent'
)}
>
<span className="block text-sm font-medium">{label}</span>
<span
className={cn(
'mt-1 block text-xs',
visibleWorkflow === value ? 'text-accent-foreground' : 'text-muted-foreground'
)}
>
{description}
</span>
</button>
))}
</div>
)
}
export function RuntimeServerShareSection({
shareServerFormOpen,
onToggleShareServerForm
}: {
shareServerFormOpen: boolean
onToggleShareServerForm: () => void
}): React.JSX.Element {
return (
<div className="space-y-3 pt-2">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisApp',
'Advertise this app as a server'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisAppHelp',
'Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.'
)}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border/50 bg-card/30">
<div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55',
'Share this Orca server'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05',
'Create a revocable access grant so a browser or another Orca client can connect.'
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={onToggleShareServerForm}
>
<Share2 />
{shareServerFormOpen
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.54dee18f5c',
'Hide Form'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.3595fd1948',
'New Link'
)}
</Button>
</div>
<div className="border-t border-border/40 px-3 py-3">
<RuntimePairingUrlGenerator
framed={false}
showHeader={false}
showGeneratorForm={shareServerFormOpen}
/>
</div>
</div>
</div>
)
}
export function RuntimeServerTroubleshooting(): React.JSX.Element {
return (
<details className="group rounded-lg border border-border/60">
<summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootWorkflow',
'Connection troubleshooting'
)}
<ChevronDown className="ml-auto size-4 transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-4 border-t border-border/50 p-4">
<div className="space-y-1">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTitle',
'Create a new link on the other host'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootDescription',
'A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.'
)}
</p>
</div>
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepShare',
'On the other computer, open Share this host.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepAddress',
'Choose Another device and select its Tailscale or LAN address.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepRegenerate',
'Generate a new access link and use only the newest link here.'
)}
</li>
</ol>
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-xs">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTunnel',
'Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.'
)}
</div>
</div>
</details>
)
}
@@ -0,0 +1,187 @@
import { Loader2, Plus, RefreshCw } from 'lucide-react'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { UpdateCheckOptions } from '../../../../shared/update-status-types'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options'
import { Button } from '../ui/button'
import type { RuntimeHostDetails } from './runtime-environment-host-details'
import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm'
import { RuntimeServerRow } from './runtime-server-row'
type RuntimeServersConnectSectionProps = {
visible: boolean
environments: PublicKnownRuntimeEnvironment[]
detailsByEnvironmentId: Record<string, RuntimeHostDetails>
activeRuntimeEnvironmentId: string | null | undefined
addServerFormOpen: boolean
name: string
pairingCode: string
addServerFailure: RuntimeHostAccessFailure | null
isBusy: boolean
remoteServerUpdates: Map<string, RemoteServerUpdateEntry>
remoteServerUpdatesChecking: boolean
remoteServerUpdatesRunning: boolean
connectingId: string | null
switchingValue: string | null
disconnectingId: string | null
removingId: string | null
onOpenAddServerForm: () => void
onCloseAddServerForm: () => void
onNameChange: (value: string) => void
onPairingCodeChange: (value: string) => void
onAddEnvironment: (allowLoopback: boolean) => void
onOpenUpdateDialog: () => void
refreshRemoteServerUpdates: (options?: UpdateCheckOptions) => Promise<void>
onConnect: (environment: PublicKnownRuntimeEnvironment) => void
onDisconnect: (environment: PublicKnownRuntimeEnvironment) => void
onRemove: (environment: PublicKnownRuntimeEnvironment) => void
}
export function RuntimeServersConnectSection({
visible,
environments,
detailsByEnvironmentId,
activeRuntimeEnvironmentId,
addServerFormOpen,
name,
pairingCode,
addServerFailure,
isBusy,
remoteServerUpdates,
remoteServerUpdatesChecking,
remoteServerUpdatesRunning,
connectingId,
switchingValue,
disconnectingId,
removingId,
onOpenAddServerForm,
onCloseAddServerForm,
onNameChange,
onPairingCodeChange,
onAddEnvironment,
onOpenUpdateDialog,
refreshRemoteServerUpdates,
onConnect,
onDisconnect,
onRemove
}: RuntimeServersConnectSectionProps): React.JSX.Element {
const updateCheckHint = getUpdateCheckHint()
return (
<div className={cn('space-y-3', !visible && 'hidden')}>
<div
data-settings-section="remote-server-updates"
className="flex items-center justify-between gap-3"
>
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServers',
'Connect to remote servers'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServersHelp',
'Pair another Orca runtime, then connect or disconnect it here.'
)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{environments.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
title={updateCheckHint}
onClick={(event) => {
onOpenUpdateDialog()
void refreshRemoteServerUpdates(getUpdateCheckClickOptions(event))
}}
disabled={remoteServerUpdatesChecking && remoteServerUpdates.size === 0}
>
{remoteServerUpdatesChecking || remoteServerUpdatesRunning ? (
<Loader2 className="animate-spin" />
) : (
<RefreshCw />
)}
{remoteServerUpdatesRunning
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.updatingServers',
'Updating servers…'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.reviewServerUpdates',
'Check for Server Updates'
)}
</Button>
) : null}
{addServerFormOpen ? null : (
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={onOpenAddServerForm}
disabled={isBusy}
>
<Plus />
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.9bee6bbeeb',
'Add Server'
)}
</Button>
)}
</div>
</div>
{addServerFormOpen ? (
<RuntimeHostAccessForm
name={name}
accessLink={pairingCode}
busy={isBusy}
failure={addServerFailure}
onNameChange={onNameChange}
onAccessLinkChange={onPairingCodeChange}
onCancel={onCloseAddServerForm}
onSubmit={onAddEnvironment}
/>
) : null}
<div className="rounded-lg border border-border/50 bg-card/30">
{environments.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.9a3758d983',
'No saved servers.'
)}
</div>
) : (
<div className="divide-y divide-border/50">
{environments.map((environment) => (
<RuntimeServerRow
key={environment.id}
environment={environment}
details={detailsByEnvironmentId[environment.id]}
isActive={activeRuntimeEnvironmentId === environment.id}
remoteUpdate={remoteServerUpdates.get(environment.id)}
remoteServerUpdatesRunning={remoteServerUpdatesRunning}
connecting={connectingId === environment.id}
switching={switchingValue === environment.id}
disconnecting={disconnectingId === environment.id}
removing={removingId === environment.id}
isBusy={isBusy}
onOpenUpdate={onOpenUpdateDialog}
onConnect={onConnect}
onDisconnect={onDisconnect}
onRemove={onRemove}
/>
))}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,163 @@
import {
useCallback,
useEffect,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { toast } from 'sonner'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import {
isUserManagedRuntimeEnvironment,
type PublicKnownRuntimeEnvironment
} from '../../../../shared/runtime-environments'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { evaluateHostDetails, type RuntimeHostDetails } from './runtime-environment-host-details'
type RuntimeEnvironmentCatalog = {
environments: PublicKnownRuntimeEnvironment[]
isLoading: boolean
detailsByEnvironmentId: Record<string, RuntimeHostDetails>
setDetailsByEnvironmentId: Dispatch<SetStateAction<Record<string, RuntimeHostDetails>>>
mountedRef: MutableRefObject<boolean>
loadEnvironments: (verified?: {
environmentId: string
runtimeStatus: RuntimeStatus
}) => Promise<void>
}
export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog {
const [environments, setEnvironments] = useState<PublicKnownRuntimeEnvironment[]>([])
const [isLoading, setIsLoading] = useState(false)
const [detailsByEnvironmentId, setDetailsByEnvironmentId] = useState<
Record<string, RuntimeHostDetails>
>({})
const mountedRef = useMountedRef()
const loadEnvironments = useCallback(
async (verified?: { environmentId: string; runtimeStatus: RuntimeStatus }): Promise<void> => {
if (mountedRef.current) {
setIsLoading(true)
}
try {
const nextEnvironments = await window.api.runtimeEnvironments.list()
const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment)
// Why: drop store status for servers no longer saved so stale hosts don't
// linger in the sidebar registry.
useAppStore.getState().setRuntimeEnvironments(nextEnvironments)
if (verified) {
useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, {
status: verified.runtimeStatus,
checkedAt: Date.now()
})
}
if (mountedRef.current) {
setEnvironments(visibleEnvironments)
setDetailsByEnvironmentId((current) => {
const next: Record<string, RuntimeHostDetails> = {}
for (const environment of visibleEnvironments) {
next[environment.id] =
verified?.environmentId === environment.id
? {
status: 'ready',
runtimeStatus: verified.runtimeStatus,
compatibility: evaluateHostDetails(verified.runtimeStatus),
error: null
}
: (current[environment.id] ?? {
status: 'loading',
runtimeStatus: null,
compatibility: null,
error: null
})
}
return next
})
}
await Promise.allSettled(
visibleEnvironments
.filter((environment) => environment.id !== verified?.environmentId)
.map(async (environment) => {
try {
const response = await window.api.runtimeEnvironments.getStatus({
selector: environment.id,
timeoutMs: 10_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
// Why: feed the live status into the store so sidebar host pickers
// reflect manual refreshes, not just the settings pane.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: runtimeStatus,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'ready',
runtimeStatus,
compatibility: evaluateHostDetails(runtimeStatus),
error: null
}
}))
} catch (error) {
// Why: record the failed probe (null status) so the sidebar can
// distinguish unreachable from never-checked.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
if (!mountedRef.current) {
return
}
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'error',
runtimeStatus: null,
compatibility: null,
error: error instanceof Error ? error.message : String(error)
}
}))
}
})
)
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3',
'Failed to load runtime environments.'
)
)
}
} finally {
if (mountedRef.current) {
setIsLoading(false)
}
}
},
[mountedRef]
)
useEffect(() => {
void loadEnvironments()
}, [loadEnvironments])
return {
environments,
isLoading,
detailsByEnvironmentId,
setDetailsByEnvironmentId,
mountedRef,
loadEnvironments
}
}
@@ -0,0 +1,212 @@
import { useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { evaluateHostDetails, type RuntimeHostDetails } from './runtime-environment-host-details'
import { LOCAL_RUNTIME_VALUE, NO_RUNTIME_VALUE } from './runtime-environment-selection'
type RuntimeEnvironmentConnectionActionParams = {
allowLocalRuntime: boolean
mountedRef: MutableRefObject<boolean>
setDetailsByEnvironmentId: Dispatch<SetStateAction<Record<string, RuntimeHostDetails>>>
setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise<boolean>
getEnvironmentLabel: (value: string) => string
}
export function useRuntimeEnvironmentConnectionActions({
allowLocalRuntime,
mountedRef,
setDetailsByEnvironmentId,
setActiveRuntimeEnvironmentPreference,
getEnvironmentLabel
}: RuntimeEnvironmentConnectionActionParams) {
const [connectingId, setConnectingId] = useState<string | null>(null)
const [switchingValue, setSwitchingValue] = useState<string | null>(null)
const [disconnectingId, setDisconnectingId] = useState<string | null>(null)
const [switchError, setSwitchError] = useState<string | null>(null)
const disconnectEnvironment = async (
environment: PublicKnownRuntimeEnvironment
): Promise<boolean> => {
setDisconnectingId(environment.id)
setSwitchError(null)
try {
await window.api.runtimeEnvironments.disconnect({ selector: environment.id })
// Why: disconnect is non-destructive; keep the saved server but show the
// user that this live client is no longer attached to it.
useAppStore.getState().setRuntimeEnvironmentStatus(
environment.id,
{
status: null,
checkedAt: Date.now()
},
{ suppressDisconnectToast: true }
)
if (mountedRef.current) {
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'error',
runtimeStatus: null,
compatibility: null,
error: null
}
}))
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.disconnectedServer',
'Disconnected from {{value0}}.',
{ value0: environment.name }
)
)
}
return true
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to disconnect server.'
if (mountedRef.current) {
setSwitchError(message)
toast.error(message)
}
return false
} finally {
if (mountedRef.current) {
setDisconnectingId(null)
}
}
}
const connectEnvironment = async (
environment: PublicKnownRuntimeEnvironment
): Promise<boolean> => {
setConnectingId(environment.id)
setSwitchError(null)
try {
const response = await window.api.runtimeEnvironments.connect({
selector: environment.id,
timeoutMs: 15_000
})
const runtimeStatus = unwrapRuntimeRpcResult<RuntimeStatus>(response)
const compatibility = evaluateHostDetails(runtimeStatus)
// Why: row Connect is reachability only. The Advanced selector is the
// explicit default-host control and should be the only active-server path.
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: runtimeStatus,
checkedAt: Date.now()
})
if (mountedRef.current) {
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'ready',
runtimeStatus,
compatibility,
error: null
}
}))
}
if (compatibility.kind === 'blocked') {
const message = describeRuntimeCompatBlock(compatibility)
if (mountedRef.current) {
setSwitchError(message)
toast.error(message)
}
return false
}
const store = useAppStore.getState()
// Why: Connect is not the Active Server selector anymore, but connected
// hosts should still contribute their projects/workspaces to the sidebar.
const repos = await store.fetchRuntimeEnvironmentRepos(environment.id)
await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id)))
await useAppStore.getState().fetchWorktreeLineage()
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.runtimeReachable',
'{{value0}} is reachable.',
{ value0: environment.name }
)
)
}
return true
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect server.'
useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, {
status: null,
checkedAt: Date.now()
})
if (mountedRef.current) {
setDetailsByEnvironmentId((current) => ({
...current,
[environment.id]: {
status: 'error',
runtimeStatus: null,
compatibility: null,
error: message
}
}))
setSwitchError(message)
toast.error(message)
}
return false
} finally {
if (mountedRef.current) {
setConnectingId(null)
}
}
}
const switchToValue = async (value: string): Promise<boolean> => {
if (value === NO_RUNTIME_VALUE) {
return false
}
setSwitchingValue(value)
setSwitchError(null)
try {
const switched = await setActiveRuntimeEnvironmentPreference(
allowLocalRuntime && value === LOCAL_RUNTIME_VALUE ? null : value
)
if (switched) {
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.99ac81fb43',
'Switched to {{value0}}.',
{ value0: getEnvironmentLabel(value) }
)
)
}
return true
}
if (mountedRef.current) {
setSwitchError('Could not switch servers. Fix the issue and try again.')
}
return false
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to switch servers.'
if (mountedRef.current) {
setSwitchError(message)
toast.error(message)
}
return false
} finally {
if (mountedRef.current) {
setSwitchingValue(null)
}
}
}
return {
connectingId,
switchingValue,
disconnectingId,
switchError,
setSwitchError,
connectEnvironment,
disconnectEnvironment,
switchToValue
}
}
@@ -0,0 +1,194 @@
import { useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details'
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessForm'
type RuntimeEnvironmentMutationActionParams = {
environments: PublicKnownRuntimeEnvironment[]
settings: GlobalSettings
allowLocalRuntime: boolean
mountedRef: MutableRefObject<boolean>
setAddServerFormOpen: Dispatch<SetStateAction<boolean>>
loadEnvironments: (verified?: {
environmentId: string
runtimeStatus: RuntimeStatus
}) => Promise<void>
connectEnvironment: (environment: PublicKnownRuntimeEnvironment) => Promise<boolean>
}
export function useRuntimeEnvironmentMutationActions({
environments,
settings,
allowLocalRuntime,
mountedRef,
setAddServerFormOpen,
loadEnvironments,
connectEnvironment
}: RuntimeEnvironmentMutationActionParams) {
const [isSaving, setIsSaving] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(null)
const [removeError, setRemoveError] = useState<string | null>(null)
const [name, setName] = useState('')
const [pairingCode, setPairingCode] = useState('')
const [addServerFailure, setAddServerFailure] = useState<RuntimeHostAccessFailure | null>(null)
const closeAddServerForm = (): void => {
if (isSaving) {
return
}
setAddServerFormOpen(false)
setName('')
setPairingCode('')
setAddServerFailure(null)
}
const addEnvironment = async (allowLoopback: boolean): Promise<void> => {
const trimmedName = name.trim()
const trimmedPairingCode = pairingCode.trim()
if (!trimmedName || !trimmedPairingCode) {
toast.error(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.0c55a47480',
'Name and pairing code are required.'
)
)
return
}
const duplicate = environments.find(
(environment) => environment.name.trim().toLowerCase() === trimmedName.toLowerCase()
)
if (duplicate) {
toast.error(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.5ef712f407',
'A server named "{{value0}}" already exists.',
{ value0: duplicate.name }
)
)
return
}
setAddServerFailure(null)
setIsSaving(true)
try {
const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name: trimmedName,
pairingCode: trimmedPairingCode,
allowLoopback
})
if (!result.ok) {
if (mountedRef.current) {
setAddServerFailure({ kind: result.kind, message: result.message })
}
return
}
if (mountedRef.current) {
setName('')
setPairingCode('')
}
await loadEnvironments({
environmentId: result.environment.id,
runtimeStatus: result.runtimeStatus
})
if (!allowLocalRuntime) {
const connected = await connectEnvironment(result.environment)
if (!connected) {
await window.api.runtimeEnvironments.remove({ selector: result.environment.id })
await loadEnvironments()
return
}
} else {
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df',
'Connected to {{value0}}. Use Advanced > Active Server to make it the default.',
{ value0: result.environment.name }
)
)
}
}
if (mountedRef.current) {
setAddServerFormOpen(false)
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.6cb6eae14f',
'Failed to save runtime environment.'
)
)
}
} finally {
if (mountedRef.current) {
setIsSaving(false)
}
}
}
const removeEnvironment = async (
environment: PublicKnownRuntimeEnvironment
): Promise<boolean> => {
setRemovingId(environment.id)
setRemoveError(null)
try {
if (isRuntimeEnvironmentRemovalBlocked(settings.activeRuntimeEnvironmentId, environment.id)) {
if (mountedRef.current) {
setRemoveError(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerBlocked',
'Choose another Active Server in Advanced before removing this server.'
)
)
}
return false
}
await window.api.runtimeEnvironments.remove({ selector: environment.id })
await loadEnvironments()
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.b5b5114cb0',
'Removed {{value0}}.',
{ value0: environment.name }
)
)
}
return true
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to remove runtime environment.'
if (mountedRef.current) {
setRemoveError(message)
toast.error(message)
}
return false
} finally {
if (mountedRef.current) {
setRemovingId(null)
}
}
}
return {
isSaving,
removingId,
removeError,
setRemoveError,
name,
setName,
pairingCode,
setPairingCode,
addServerFailure,
setAddServerFailure,
closeAddServerForm,
addEnvironment,
removeEnvironment
}
}