Polish run picker, inline host-add, and setup-script UI

Run-target picker:
- Group not-connected hosts under a separator (no "Not connected" heading);
  reserve the alarm icon for genuine health:'error', neutral icon otherwise.
- Make not-connected rows hoverable and open the recipe/add-host submenus on
  hover; add a separator before the environment/add-host actions.
- Disable only the in-flight host's Connect button (Set of connecting ids) and
  add a UI connect timeout so one stalled connect can't lock out the others.

Add-host from the composer:
- Open SSH / remote-server add as an inline dialog layered over the composer
  instead of navigating to Settings, so the in-progress form is preserved; the
  new host flows back into the picker via the store.
- Fix a close-animation flicker where the dialog briefly showed the SSH form.
- Expose the full SSH advanced options (proxy, jump host, connection reuse,
  terminal persistence) under one combined Advanced disclosure, shared with the
  Settings SSH form via a new SshHostAdvancedFields; remove the now-orphaned
  Settings section components.

Setup-script section:
- Neutral (non-green) code preview, single orca.yaml source chip, consistent
  rounded-md corners, run-setup as a toggle, combined into one box; disable the
  wait-for-setup toggle when setup won't run.
- Fix the whole setup section vanishing on toggle by keying the yaml-hooks load
  effect on the runtime-env id instead of the churny selectedRepoSettings object,
  and stop the wait-for-setup toggle flicker by syncing on the persisted value.

Sparse checkout dropdown:
- Rebuild the list on cmdk Command/CommandItem to match the other pickers, and
  give the preset draft form the app's standard inputs + opaque bg-popover.

Advanced disclosure: fix the highlight clipping / left-alignment in the scroll
container.
This commit is contained in:
Jinwoo-H
2026-07-09 02:32:57 -04:00
parent 85edeac53c
commit ee1f456e3a
17 changed files with 745 additions and 596 deletions
@@ -56,6 +56,13 @@ vi.mock('@/components/agent/AgentCombobox', () => ({
default: () => <button type="button">Agent picker</button>
}))
// Stub the host-add dialog to its `mode` — the composer's job is to open it with the right
// mode; the dialog's own SSH/runtime IPC has separate coverage.
vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({
AddRemoteHostDialog: ({ mode }: { mode: 'ssh' | 'server' | null }) =>
mode ? <div data-testid="add-remote-host-dialog" data-mode={mode} /> : null
}))
vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({
default: () => <div data-testid="sparse-select" />
}))
@@ -148,7 +155,8 @@ const devboxNeedsSetupHostOption: ProjectHostSetupOption = {
hostId: 'ssh:devbox',
label: 'Devbox',
detail: 'Project not set up on this host',
isAvailable: true
isAvailable: true,
attention: false
}
const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = {
@@ -159,9 +167,29 @@ const disconnectedDevboxNeedsSetupHostOption: ProjectHostSetupOption = {
label: 'Devbox',
detail: 'Connect this host to set up projects',
isAvailable: false,
attention: false,
connectAction: { kind: 'ssh', targetId: 'devbox' }
}
const disconnectedBastionNeedsSetupHostOption: ProjectHostSetupOption = {
kind: 'needs-setup',
id: 'needs-setup:ssh:bastion',
projectId: 'project-group:platform',
hostId: 'ssh:bastion',
label: 'Bastion',
detail: 'Connect this host to set up projects',
isAvailable: false,
attention: false,
connectAction: { kind: 'ssh', targetId: 'bastion' }
}
function findConnectButton(label: string): HTMLButtonElement | undefined {
const item = findRunTargetItem(label)
return [...(item?.querySelectorAll('button') ?? [])].find((button) =>
button.textContent?.includes('Connect')
)
}
function renderCard(
overrides: Partial<React.ComponentProps<typeof NewWorkspaceComposerCard>> = {}
) {
@@ -415,11 +443,12 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
)
})
it('emits the setup startup policy toggle value', () => {
it('emits the setup startup policy toggle value when setup will run', () => {
const changes: string[] = []
current = renderCard({
advancedOpen: true,
setupControlsEnabled: true,
resolvedSetupDecision: 'run',
setupConfig: {
source: 'yaml',
command: 'pnpm install',
@@ -428,14 +457,38 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
onSetupAgentStartupPolicyChange: (next) => changes.push(next)
})
const waitSwitch = current.container.querySelector<HTMLElement>(
const waitSwitch = current.container.querySelector<HTMLButtonElement>(
'[role="switch"][aria-label="Wait for setup to complete before starting agent"]'
)
expect(waitSwitch).toBeTruthy()
expect(waitSwitch?.disabled).toBe(false)
act(() => waitSwitch?.click())
expect(changes).toEqual(['wait-for-setup'])
})
it('disables the wait-for-setup toggle when setup is set to skip', () => {
const changes: string[] = []
current = renderCard({
advancedOpen: true,
setupControlsEnabled: true,
resolvedSetupDecision: 'skip',
setupConfig: {
source: 'yaml',
command: 'pnpm install',
kind: 'setup'
},
onSetupAgentStartupPolicyChange: (next) => changes.push(next)
})
const waitSwitch = current.container.querySelector<HTMLButtonElement>(
'[role="switch"][aria-label="Wait for setup to complete before starting agent"]'
)
expect(waitSwitch?.disabled).toBe(true)
// Nothing to wait for when setup won't run — clicking is inert.
act(() => waitSwitch?.click())
expect(changes).toEqual([])
})
it('shows a git-only branch name field in Advanced and emits manual edits', () => {
const changes: (string | undefined)[] = []
current = renderCard({
@@ -526,10 +579,11 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
const devboxItem = findRunTargetItem('Devbox')
expect(devboxItem?.textContent).toContain('Project not set up on this host')
expect(
devboxItem?.getAttribute('aria-disabled') === 'true' ||
devboxItem?.hasAttribute('data-disabled')
).toBe(true)
// Not-connected rows stay highlightable (not disabled) so they hover like the other
// items; a separator sets them off instead of a heading.
expect(devboxItem?.getAttribute('aria-disabled')).toBe('false')
expect(devboxItem?.getAttribute('data-disabled')).toBe('false')
expect(document.body.querySelector('[cmdk-separator]')).toBeTruthy()
})
it('shows the run target picker for one ready setup so hosts can be added', () => {
@@ -587,7 +641,39 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(findRunTargetItem('Devbox')).toBeUndefined()
})
it('opens the SSH host add form from the run target picker', () => {
it('keeps other hosts connectable while one connect is still in flight', async () => {
// First host's connect never resolves — a stalled connect must not disable the others.
apiMocks.sshConnect.mockImplementation(({ targetId }: { targetId: string }) =>
targetId === 'devbox' ? new Promise(() => {}) : Promise.resolve(undefined)
)
current = renderCard({
projectHostSetupOptions: [
localReadyHostOption,
disconnectedDevboxNeedsSetupHostOption,
disconnectedBastionNeedsSetupHostOption
],
selectedProjectHostSetupId: 'setup-local'
})
openRunTargetPicker(current.container)
await act(async () => {
findConnectButton('Devbox')?.click()
})
// Clicking Connect closes the popover; reopen to inspect the still-open connect state.
openRunTargetPicker(current.container)
// Devbox is mid-connect (disabled), but Bastion stays clickable.
expect(findConnectButton('Devbox')?.disabled).toBe(true)
const bastionButton = findConnectButton('Bastion')
expect(bastionButton?.disabled).toBe(false)
await act(async () => {
bastionButton?.click()
})
expect(apiMocks.sshConnect).toHaveBeenCalledWith({ targetId: 'bastion' })
})
it('opens the SSH host add dialog over the composer without leaving for Settings', () => {
current = renderCard({
projectHostSetupOptions: [localReadyHostOption, devboxNeedsSetupHostOption],
selectedProjectHostSetupId: 'setup-local'
@@ -597,16 +683,34 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
act(() => findRunTargetItem('Add host')?.click())
act(() => findRunTargetItem('Add SSH host')?.click())
expect(storeMocks.closeModal).toHaveBeenCalled()
expect(storeMocks.openSettingsPage).toHaveBeenCalled()
expect(storeMocks.openSettingsTarget).toHaveBeenCalledWith({
pane: 'ssh',
repoId: null,
intent: 'add-ssh-host'
})
const dialog = document.body.querySelector('[data-testid="add-remote-host-dialog"]')
expect(dialog?.getAttribute('data-mode')).toBe('ssh')
// The composer stays put — no navigation that would discard the in-progress form.
expect(storeMocks.closeModal).not.toHaveBeenCalled()
expect(storeMocks.openSettingsPage).not.toHaveBeenCalled()
expect(storeMocks.openSettingsTarget).not.toHaveBeenCalled()
})
it('opens the remote Orca server add form from the run target picker', () => {
it('opens the add-host submenu on hover without a click', () => {
current = renderCard({
projectHostSetupOptions: [localReadyHostOption, devboxNeedsSetupHostOption],
selectedProjectHostSetupId: 'setup-local'
})
openRunTargetPicker(current.container)
const addHost = findRunTargetItem('Add host')
expect(addHost).toBeTruthy()
// Hovering the row (no click) opens its submenu so it feels like a menu. React derives
// onPointerEnter from a bubbling pointerover, which is what jsdom dispatches here.
act(() => {
addHost?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))
})
expect(findRunTargetItem('Add SSH host')).toBeTruthy()
expect(findRunTargetItem('Add Remote Orca Server')).toBeTruthy()
})
it('opens the remote Orca server add dialog over the composer without leaving for Settings', () => {
current = renderCard({
projectHostSetupOptions: [localReadyHostOption, devboxNeedsSetupHostOption],
selectedProjectHostSetupId: 'setup-local'
@@ -616,13 +720,11 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
act(() => findRunTargetItem('Add host')?.click())
act(() => findRunTargetItem('Add Remote Orca Server')?.click())
expect(storeMocks.closeModal).toHaveBeenCalled()
expect(storeMocks.openSettingsPage).toHaveBeenCalled()
expect(storeMocks.openSettingsTarget).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
intent: 'add-remote-orca-server'
})
const dialog = document.body.querySelector('[data-testid="add-remote-host-dialog"]')
expect(dialog?.getAttribute('data-mode')).toBe('server')
expect(storeMocks.closeModal).not.toHaveBeenCalled()
expect(storeMocks.openSettingsPage).not.toHaveBeenCalled()
expect(storeMocks.openSettingsTarget).not.toHaveBeenCalled()
})
it('shows VM recipes inside the run target picker', () => {
@@ -20,7 +20,13 @@ import {
Server
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import {
Command,
CommandEmpty,
CommandItem,
CommandList,
CommandSeparator
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
@@ -54,6 +60,10 @@ import SmartWorkspaceNameField, {
} from '@/components/new-workspace/SmartWorkspaceNameField'
import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results'
import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
import {
AddRemoteHostDialog,
type AddRemoteHostMode
} from '@/components/sidebar/AddRemoteHostDialog'
import type { SetupConfig } from '@/lib/new-workspace'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import type {
@@ -206,6 +216,33 @@ function getSshStatusLabel(status: SshConnectionStatus): string {
return SSH_STATUS_LABELS[status] ?? status
}
// Why: bound how long the run-target picker waits on a host connect so a stalled backend
// connect can't leave the row's disabled/spinner state stuck forever. The backend keeps going.
const RUN_TARGET_CONNECT_UI_TIMEOUT_MS = 20_000
async function withUiConnectTimeout<T>(promise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(
new Error(
translate(
'auto.components.NewWorkspaceComposerCard.connectTimedOut',
'Connection timed out. It may still be connecting in the background.'
)
)
)
}, RUN_TARGET_CONNECT_UI_TIMEOUT_MS)
})
try {
return await Promise.race([promise, timeout])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
function getRecipeCommandDisplay(command: string): string {
const trimmed = command.trim()
const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/)
@@ -251,7 +288,11 @@ function WorkspaceRunTargetCombobox({
const [open, setOpen] = React.useState(false)
const [vmRecipesOpen, setVmRecipesOpen] = React.useState(false)
const [hostActionsOpen, setHostActionsOpen] = React.useState(false)
const [connectingHostId, setConnectingHostId] = React.useState<string | null>(null)
// Why: track in-flight connects per host (a Set, not a single id) so connecting one host — or
// one connect stalling — never disables connecting to the others.
const [connectingHostIds, setConnectingHostIds] = React.useState<ReadonlySet<string>>(
() => new Set()
)
const mountedRef = React.useRef(true)
const readyHostOptions = React.useMemo(
() =>
@@ -260,6 +301,13 @@ function WorkspaceRunTargetCombobox({
),
[hostOptions]
)
const needsSetupHostOptions = React.useMemo(
() =>
hostOptions.filter(
(option): option is NeedsSetupProjectHostOption => option.kind === 'needs-setup'
),
[hostOptions]
)
const selectedHost =
readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null
const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null
@@ -305,21 +353,47 @@ function WorkspaceRunTargetCombobox({
const connectHost = React.useCallback(
async (option: NeedsSetupProjectHostOption): Promise<void> => {
if (!option.connectAction || !onConnectHost || connectingHostId !== null) {
if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) {
return
}
setConnectingHostId(option.hostId)
setConnectingHostIds((current) => new Set(current).add(option.hostId))
try {
await onConnectHost(option)
} finally {
if (mountedRef.current) {
setConnectingHostId((current) => (current === option.hostId ? null : current))
setConnectingHostIds((current) => {
if (!current.has(option.hostId)) {
return current
}
const next = new Set(current)
next.delete(option.hostId)
return next
})
}
}
},
[connectingHostId, onConnectHost]
[connectingHostIds, onConnectHost]
)
// Why: the submenu rows open their popover on hover/focus (not just click) to feel like a
// menu; opening one closes the other so the two right-side popovers never stack.
const openVmRecipesSubmenu = React.useCallback((): void => {
setHostActionsOpen(false)
setVmRecipesOpen(true)
}, [])
const openHostActionsSubmenu = React.useCallback((): void => {
setVmRecipesOpen(false)
setHostActionsOpen(true)
}, [])
// Why: hovering/highlighting a plain host row dismisses any submenu popover left open from
// passing over the recipe/add-host rows, so a stray submenu can't linger over the list.
const closeSubmenus = React.useCallback((): void => {
setVmRecipesOpen(false)
setHostActionsOpen(false)
}, [])
const handleAddSshHost = React.useCallback((): void => {
setHostActionsOpen(false)
setOpen(false)
@@ -377,77 +451,99 @@ function WorkspaceRunTargetCombobox({
'No run targets are ready for this project.'
)}
</CommandEmpty>
{hostOptions.map((option) =>
option.kind === 'ready' ? (
<CommandItem
key={option.id}
value={`host:${option.id}`}
onSelect={() => handleHostSelect(option.id)}
className="items-center gap-2 px-3 py-2"
>
<Check
className={cn(
'size-4 text-foreground',
!selectedRecipe && option.id === selectedHost?.id
? 'opacity-100'
: 'opacity-0'
)}
/>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.path}
</div>
{readyHostOptions.map((option) => (
<CommandItem
key={option.id}
value={`host:${option.id}`}
onSelect={() => handleHostSelect(option.id)}
onPointerEnter={closeSubmenus}
onFocus={closeSubmenus}
className="items-center gap-2 px-3 py-2"
>
<Check
className={cn(
'size-4 text-foreground',
!selectedRecipe && option.id === selectedHost?.id ? 'opacity-100' : 'opacity-0'
)}
/>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.path}
</div>
</CommandItem>
) : (
// Why: setup-on-host is a follow-up; this row only explains why the host cannot
// run the workspace yet.
<CommandItem
key={option.id}
value={option.id}
disabled
className="items-center gap-2 px-3 py-2 data-[disabled=true]:pointer-events-auto data-[disabled=true]:opacity-100"
>
<div className="flex min-w-0 flex-1 items-center gap-2 opacity-50">
<Check className="size-4 opacity-0" />
{option.isAvailable ? (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.detail}
</div>
</CommandItem>
))}
{/* Why: not-connected hosts are dormant, not errors — a separator (no heading) sets
them off as a secondary group without labeling the recipe/add-host rows below. */}
{needsSetupHostOptions.length > 0 ? (
<>
<CommandSeparator />
{needsSetupHostOptions.map((option) => (
// Why: setup-on-host is a follow-up; this row only explains why the host cannot
// run the workspace yet. It stays highlightable (no `disabled`) so it matches the
// hover of the other rows, but selecting it is a no-op since it isn't ready.
<CommandItem
key={option.id}
value={option.id}
onSelect={() => {}}
onPointerEnter={closeSubmenus}
onFocus={closeSubmenus}
className="items-center gap-2 px-3 py-2"
>
<div className="flex min-w-0 flex-1 items-center gap-2 opacity-60">
<Check className="size-4 opacity-0" />
{/* Why: reserve the alarm glyph for a genuine connection error; a dormant
disconnected host gets the neutral server icon. */}
{option.attention ? (
<AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.detail}
</div>
</div>
</div>
</div>
{option.connectAction && onConnectHost ? (
<Button
type="button"
variant="ghost"
size="xs"
className="ml-1 shrink-0 text-muted-foreground/50 hover:text-muted-foreground"
disabled={connectingHostId !== null}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setOpen(false)
void connectHost(option)
}}
>
{translate('auto.components.NewWorkspaceComposerCard.connectHost', 'Connect')}
</Button>
) : null}
</CommandItem>
)
)}
{option.connectAction && onConnectHost ? (
<Button
type="button"
variant="ghost"
size="xs"
className="ml-1 shrink-0 text-muted-foreground/50 hover:text-muted-foreground"
// Why: only disable the row being connected — not every row — so one
// stuck/slow connect can't lock out connecting to the other hosts.
disabled={connectingHostIds.has(option.hostId)}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setOpen(false)
void connectHost(option)
}}
>
{translate(
'auto.components.NewWorkspaceComposerCard.connectHost',
'Connect'
)}
</Button>
) : null}
</CommandItem>
))}
</>
) : null}
{/* Why: separate the host list from the environment/add-host actions below, mirroring
the divider above the not-connected group so the two action rows read as their own
section rather than trailing the hosts. */}
{readyHostOptions.length > 0 || needsSetupHostOptions.length > 0 ? (
<CommandSeparator />
) : null}
{recipes.length > 0 ? (
<Popover open={vmRecipesOpen} onOpenChange={setVmRecipesOpen}>
<PopoverTrigger asChild>
@@ -455,7 +551,9 @@ function WorkspaceRunTargetCombobox({
only rendering under the first host, the uneven height, and the double-highlight. */}
<CommandItem
value="per-workspace-env"
onSelect={() => setVmRecipesOpen(true)}
onSelect={openVmRecipesSubmenu}
onPointerEnter={openVmRecipesSubmenu}
onFocus={openVmRecipesSubmenu}
className="items-center gap-2 px-3 py-2"
>
<Check
@@ -518,7 +616,9 @@ function WorkspaceRunTargetCombobox({
<PopoverTrigger asChild>
<CommandItem
value="add-host"
onSelect={() => setHostActionsOpen(true)}
onSelect={openHostActionsSubmenu}
onPointerEnter={openHostActionsSubmenu}
onFocus={openHostActionsSubmenu}
className="items-center gap-2 px-3 py-2"
>
<Check className="size-4 opacity-0" />
@@ -538,7 +638,9 @@ function WorkspaceRunTargetCombobox({
</CommandItem>
</PopoverTrigger>
<PopoverContent side="right" align="start" sideOffset={6} className="w-72 p-0">
<Command>
{/* Why: pin an empty value so cmdk doesn't auto-highlight the first row on open —
matches the recipes submenu, which leaves nothing highlighted by default. */}
<Command value="">
<CommandList>
<CommandItem
value="add-ssh-host"
@@ -593,47 +695,14 @@ function WorkspaceRunTargetCombobox({
)
}
function SetupCommandPreview({
setupConfig,
headerAction
}: {
setupConfig: SetupConfig
headerAction?: React.ReactNode
}): React.JSX.Element {
if (setupConfig.source === 'yaml') {
return (
<div className="rounded-2xl border border-border/60 bg-muted/40 shadow-inner">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-4 py-2.5">
<div className="font-mono text-[11px] text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.23bb365554', 'orca.yaml')}
</div>
{headerAction}
</div>
{/* Why: long orca.yaml scripts must not grow the create dialog past the viewport. */}
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words px-4 py-3 font-mono text-[12px] leading-5 text-emerald-700 scrollbar-sleek dark:text-emerald-300/95">
{setupConfig.command}
</pre>
</div>
)
}
function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element {
// Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and
// the run-setup toggle live in the section header above, so the card carries no chrome of its
// own. Neutral foreground avoids the colored-terminal look. max-h keeps long scripts from
// growing the dialog past the viewport.
return (
<div className="rounded-2xl border border-border/60 bg-muted/35 px-4 py-3 shadow-inner">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
{setupConfig.source === 'both'
? translate(
'auto.components.NewWorkspaceComposerCard.e5db1b0419',
'Combined setup command'
)
: translate(
'auto.components.NewWorkspaceComposerCard.7711ad5122',
'Local setup command'
)}
</div>
{headerAction}
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[12px] leading-5 text-foreground scrollbar-sleek">
<div className="rounded-md border border-border/60 bg-muted/40 shadow-inner">
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words px-4 py-3 font-mono text-[12px] leading-5 text-foreground/90 scrollbar-sleek">
{setupConfig.command}
</pre>
</div>
@@ -798,10 +867,7 @@ export default function NewWorkspaceComposerCard({
useTranslation()
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
const openModal = useAppStore((s) => s.openModal)
const closeModal = useAppStore((s) => s.closeModal)
const activeModal = useAppStore((s) => s.activeModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null)
const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? [])
const updateSettings = useAppStore((s) => s.updateSettings)
@@ -909,26 +975,16 @@ export default function NewWorkspaceComposerCard({
const handleAddRepo = React.useCallback((): void => {
openModal('add-repo')
}, [openModal])
const openHostSettingsTarget = React.useCallback(
(target: 'add-remote-orca-server' | 'add-ssh-host'): void => {
// Why: the composer can be modal; close it before deep-linking to the
// existing host-management forms in Settings.
closeModal()
openSettingsPage()
openSettingsTarget({
pane: target === 'add-ssh-host' ? 'ssh' : 'servers',
repoId: null,
intent: target
})
},
[closeModal, openSettingsPage, openSettingsTarget]
)
// Why: open the host-add form inline over the composer (not via Settings) so the user's
// in-progress workspace form survives; the new host lands in the store and flows straight
// back into the run-target picker without a navigation round-trip.
const [addRemoteHostMode, setAddRemoteHostMode] = React.useState<AddRemoteHostMode | null>(null)
const handleAddSshHost = React.useCallback((): void => {
openHostSettingsTarget('add-ssh-host')
}, [openHostSettingsTarget])
setAddRemoteHostMode('ssh')
}, [])
const handleAddRemoteServer = React.useCallback((): void => {
openHostSettingsTarget('add-remote-orca-server')
}, [openHostSettingsTarget])
setAddRemoteHostMode('server')
}, [])
const handleConnectRunTargetHost = React.useCallback(
async (option: NeedsSetupProjectHostOption): Promise<void> => {
const action = option.connectAction
@@ -937,7 +993,10 @@ export default function NewWorkspaceComposerCard({
}
try {
if (action.kind === 'ssh') {
await window.api.ssh.connect({ targetId: action.targetId })
// Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave the
// row's spinner/disabled state stuck forever. Bound the UI wait — the backend keeps
// connecting and the picker updates from store SSH state if it later succeeds.
await withUiConnectTimeout(window.api.ssh.connect({ targetId: action.targetId }))
return
}
@@ -1340,6 +1399,9 @@ export default function NewWorkspaceComposerCard({
with the content or footer below it while preserving normal spacing
from the Agent field above. */}
<div className="!mb-2">
{/* Why: -ml-2 pulls the button so its label aligns flush-left with the field labels above
while the padded hover highlight extends past the label on the left. The scroll
container's px-2 inset gives that overhang room so it isn't clipped. */}
<Button
type="button"
variant="ghost"
@@ -1462,7 +1524,9 @@ export default function NewWorkspaceComposerCard({
<label className="text-xs font-medium text-muted-foreground">
{setupConfigLabel}
</label>
<span className="rounded-full border border-border/70 bg-muted/45 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-foreground/70">
{/* Why: a quiet monospace filename chip (not an uppercase tag) — orca.yaml is a
literal filename, so it reads as code, matching the app's path styling. */}
<span className="rounded border border-border/50 bg-muted/30 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{setupConfig.source === 'yaml'
? translate(
'auto.components.NewWorkspaceComposerCard.23bb365554',
@@ -1483,39 +1547,70 @@ export default function NewWorkspaceComposerCard({
{/* Why: `orca.yaml` is the committed source of truth for shared setup,
so the preview reconstructs the real YAML shape instead of showing a raw
shell blob that hides where the command came from. */}
<SetupCommandPreview
setupConfig={setupConfig}
headerAction={
requiresExplicitSetupChoice ? null : (
<label className="group flex items-center gap-2 text-xs text-foreground">
<SetupCommandPreview setupConfig={setupConfig} />
{/* Why: group the run-setup and wait-for-setup toggles in one bordered box so
they read as a single settings cluster, aligned hard-right. */}
{!requiresExplicitSetupChoice || showSetupAgentStartupPolicy ? (
<div className="rounded-md border border-border/60 bg-muted/25">
{requiresExplicitSetupChoice ? null : (
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-xs font-medium text-foreground">
{setupRunLabel}
</span>
<SettingsSwitch
checked={resolvedSetupDecision === 'run'}
onChange={() =>
onSetupDecisionChange(
resolvedSetupDecision === 'run' ? 'skip' : 'run'
)
}
ariaLabel={setupRunLabel}
/>
</div>
)}
{showSetupAgentStartupPolicy ? (
// Why: nothing to wait for when setup won't run — disable the toggle and
// dim the label so it reads as inactive (the switch dims itself).
<div className="flex items-start justify-between gap-3 p-3">
<span
className={cn(
'flex size-4 items-center justify-center rounded-[3px] border transition shadow-sm',
resolvedSetupDecision === 'run'
? 'border-emerald-500/60 bg-emerald-500 text-white'
: 'border-foreground/20 bg-background dark:border-white/20 dark:bg-muted/10'
'min-w-0 space-y-1',
resolvedSetupDecision === 'run' ? '' : 'opacity-50'
)}
>
<Check
className={cn(
'size-3 transition-opacity',
resolvedSetupDecision === 'run' ? 'opacity-100' : 'opacity-0'
<span className="block text-xs font-medium text-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
/>
</span>
<span className="block text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</span>
</span>
<input
type="checkbox"
checked={resolvedSetupDecision === 'run'}
onChange={(event) =>
onSetupDecisionChange(event.target.checked ? 'run' : 'skip')
<SettingsSwitch
checked={setupAgentStartupPolicy === 'wait-for-setup'}
disabled={resolvedSetupDecision !== 'run'}
onChange={() =>
onSetupAgentStartupPolicyChange(
setupAgentStartupPolicy === 'wait-for-setup'
? 'start-immediately'
: 'wait-for-setup'
)
}
className="sr-only"
ariaLabel={translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
/>
<span>{setupRunLabel}</span>
</label>
)
}
/>
</div>
) : null}
</div>
) : null}
{requiresExplicitSetupChoice ? (
<div className="space-y-2">
@@ -1555,39 +1650,6 @@ export default function NewWorkspaceComposerCard({
) : null}
</div>
) : null}
{showSetupAgentStartupPolicy ? (
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-muted/25 p-3">
<span className="min-w-0 space-y-1">
<span className="block text-xs font-medium text-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</span>
<span className="block text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</span>
</span>
<SettingsSwitch
checked={setupAgentStartupPolicy === 'wait-for-setup'}
onChange={() =>
onSetupAgentStartupPolicyChange(
setupAgentStartupPolicy === 'wait-for-setup'
? 'start-immediately'
: 'wait-for-setup'
)
}
ariaLabel={translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
/>
</div>
) : null}
</div>
) : null}
@@ -1685,6 +1747,10 @@ export default function NewWorkspaceComposerCard({
</span>
</Button>
</div>
{/* Why: layer the host-add form over the composer instead of navigating to Settings so
the in-progress workspace form is preserved; on success the new host flows back into
the run-target picker via the store. */}
<AddRemoteHostDialog mode={addRemoteHostMode} onOpenChange={setAddRemoteHostMode} />
</div>
)
}
@@ -258,10 +258,11 @@ function QuickTabBody({
</DialogHeader>
<NewWorkspaceComposerCard
contextualTourSource={modalData.contextualTourSource}
// Why: the scroll container clips children, while Orca's standard
// field focus ring paints 3px outside the control. Inset both sides so
// keyboard focus stays fully visible at the dialog edges.
containerClassName="min-h-0 flex-1 overflow-y-auto px-1 scrollbar-sleek"
// Why: the scroll container clips children (overflow-y-auto forces overflow-x to auto),
// while Orca's standard field focus ring paints 3px outside the control and the ghost
// "Advanced" disclosure pulls its padded hover highlight ~8px left to align its label with
// the field labels. Inset px-2 so both stay fully visible instead of clipped at the edge.
containerClassName="min-h-0 flex-1 overflow-y-auto px-2 scrollbar-sleek"
composerRef={composerRef}
onComposerNodeChange={onComposerNodeChange}
nameInputRef={nameInputRef}
@@ -62,7 +62,8 @@ const needsSetupOption: NeedsSetupProjectHostOption = {
hostId: 'ssh:builder',
label: 'Builder',
detail: 'Project not set up on this host',
isAvailable: true
isAvailable: true,
attention: false
}
const unavailableOption: NeedsSetupProjectHostOption = {
@@ -72,7 +73,8 @@ const unavailableOption: NeedsSetupProjectHostOption = {
hostId: 'runtime:old',
label: 'Old server',
detail: 'Update Orca on this host to set up projects',
isAvailable: false
isAvailable: false,
attention: false
}
beforeEach(() => {
@@ -0,0 +1,178 @@
import { ChevronDown } from 'lucide-react'
import {
DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS,
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS
} from '../../../../shared/ssh-types'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { SettingsSwitch } from './SettingsFormControls'
import type { EditingTarget } from './ssh-target-draft'
import { translate } from '@/i18n/i18n'
// Why: mirror the composer's "Advanced" disclosure (ghost button + rotating chevron) so the
// add-host dialog reads the same, while reusing the exact field labels/help text from the
// Settings SSH form (proxy, jump host, connection reuse, terminal persistence).
export function SshHostAdvancedFields({
open,
onOpenChange,
form,
disabled,
onFormChange
}: {
open: boolean
onOpenChange: (open: boolean) => void
form: EditingTarget
disabled: boolean
onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void
}): React.JSX.Element {
return (
<Collapsible open={open} onOpenChange={onOpenChange} className="col-span-2 sm:col-span-2">
<CollapsibleTrigger asChild>
{/* Why: no negative margin — the button's highlight would overhang and get clipped by the
dialog/settings containers. A small left padding keeps it flush without overflow. */}
<Button type="button" variant="ghost" size="sm" className="px-2 text-xs">
{translate('auto.components.sidebar.AddRemoteHostDialog.advanced', 'Advanced')}
<ChevronDown className={cn('size-4 transition-transform', open && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="collapsible-height-content">
<div className="space-y-4 pt-3">
<div className="space-y-1.5">
<Label htmlFor="add-ssh-proxy-command">
{translate('auto.components.settings.SshTargetForm.c7d0e18ecb', 'Proxy Command')}
</Label>
<Input
id="add-ssh-proxy-command"
value={form.proxyCommand}
disabled={disabled}
onChange={(e) => onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))}
placeholder={translate(
'auto.components.settings.SshTargetForm.f42d844544',
'e.g. cloudflared access ssh --hostname %h'
)}
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.3b01ca44a0',
'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).'
)}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="add-ssh-jump-host">
{translate('auto.components.settings.SshTargetForm.b2ab248ded', 'Jump Host')}
</Label>
<Input
id="add-ssh-jump-host"
value={form.jumpHost}
disabled={disabled}
onChange={(e) => onFormChange((f) => ({ ...f, jumpHost: e.target.value }))}
placeholder={translate(
'auto.components.settings.SshTargetForm.11bcb4507a',
'bastion.example.com'
)}
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.feae1d1e69',
'Optional. Equivalent to ProxyJump / ssh -J.'
)}
</p>
</div>
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
{translate(
'auto.components.settings.SshTargetForm.8c922dffba',
'Reuse SSH connection for faster setup'
)}
</Label>
<p className="text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.53e9aabfc0',
'Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.'
)}
</p>
</div>
<SettingsSwitch
checked={form.systemSshConnectionReuse}
onChange={() =>
onFormChange((f) => ({
...f,
systemSshConnectionReuse: !f.systemSshConnectionReuse
}))
}
ariaLabel={translate(
'auto.components.settings.SshTargetForm.8c922dffba',
'Reuse SSH connection for faster setup'
)}
/>
</div>
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
{translate(
'auto.components.settings.SshTargetForm.71fc546097',
'Keep terminals alive until reset'
)}
</Label>
<p className="text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.b574994adc',
'Use End Remote Terminals or Reset Relay when you want to stop them.'
)}
</p>
</div>
<SettingsSwitch
checked={form.relayKeepAliveUntilReset}
onChange={() =>
onFormChange((f) => ({
...f,
relayKeepAliveUntilReset: !f.relayKeepAliveUntilReset
}))
}
ariaLabel={translate(
'auto.components.settings.SshTargetForm.71fc546097',
'Keep terminals alive until reset'
)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="add-ssh-relay-grace-period" className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.55c56cf2c7',
'Timeout after disconnect (seconds)'
)}
</Label>
<Input
id="add-ssh-relay-grace-period"
type={form.relayKeepAliveUntilReset ? 'text' : 'number'}
value={
form.relayKeepAliveUntilReset
? translate('auto.components.settings.SshTargetForm.7c13f58c91', 'Until reset')
: form.relayGracePeriodSeconds
}
disabled={disabled || form.relayKeepAliveUntilReset}
onChange={(e) =>
onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))
}
placeholder={String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS)}
min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS}
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.1b19b00e93',
'Bounded timeouts must be between 60 seconds and 7 days.'
)}
</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
)
}
@@ -1,106 +0,0 @@
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { SettingsSwitch } from './SettingsFormControls'
import { SshTargetFormCollapsibleSection } from './SshTargetFormCollapsibleSection'
import type { EditingTarget } from './ssh-target-draft'
import { translate } from '@/i18n/i18n'
type SshTargetAdvancedConnectionSectionProps = {
open: boolean
onOpenChange: (open: boolean) => void
form: EditingTarget
onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void
}
export function SshTargetAdvancedConnectionSection({
open,
onOpenChange,
form,
onFormChange
}: SshTargetAdvancedConnectionSectionProps): React.JSX.Element {
return (
<SshTargetFormCollapsibleSection
open={open}
onOpenChange={onOpenChange}
title={translate('auto.components.settings.SshTargetForm.4a342f44c1', 'Advanced Connection')}
description={translate(
'auto.components.settings.SshTargetForm.e9609ddca6',
'Proxy, jump host, and connection reuse'
)}
>
<div className="space-y-1.5">
<Label>
{translate('auto.components.settings.SshTargetForm.c7d0e18ecb', 'Proxy Command')}
</Label>
<Input
value={form.proxyCommand}
onChange={(e) => onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))}
placeholder={translate(
'auto.components.settings.SshTargetForm.f42d844544',
'e.g. cloudflared access ssh --hostname %h'
)}
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.3b01ca44a0',
'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).'
)}
</p>
</div>
<div className="space-y-1.5">
<Label>{translate('auto.components.settings.SshTargetForm.b2ab248ded', 'Jump Host')}</Label>
<Input
value={form.jumpHost}
onChange={(e) => onFormChange((f) => ({ ...f, jumpHost: e.target.value }))}
placeholder={translate(
'auto.components.settings.SshTargetForm.11bcb4507a',
'bastion.example.com'
)}
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.feae1d1e69',
'Optional. Equivalent to ProxyJump / ssh -J.'
)}
</p>
</div>
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
{translate(
'auto.components.settings.SshTargetForm.8c922dffba',
'Reuse SSH connection for faster setup'
)}
</Label>
<p className="text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.53e9aabfc0',
'Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.'
)}
</p>
</div>
<SettingsSwitch
checked={form.systemSshConnectionReuse}
onChange={() =>
onFormChange((f) => ({
...f,
systemSshConnectionReuse: !f.systemSshConnectionReuse
}))
}
ariaLabel={translate(
'auto.components.settings.SshTargetForm.8c922dffba',
'Reuse SSH connection for faster setup'
)}
/>
</div>
</SshTargetFormCollapsibleSection>
)
}
export function hasAdvancedConnectionValues(form: EditingTarget): boolean {
return (
form.proxyCommand.trim().length > 0 ||
form.jumpHost.trim().length > 0 ||
!form.systemSshConnectionReuse
)
}
@@ -3,12 +3,12 @@ import { FileKey } from 'lucide-react'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { SshHostAdvancedFields } from './SshHostAdvancedFields'
import {
SshTargetAdvancedConnectionSection,
hasAdvancedConnectionValues
} from './SshTargetAdvancedConnectionSection'
import { SshTargetTerminalPersistenceSection } from './SshTargetTerminalPersistenceSection'
import { applyParsedSshHostInput, type EditingTarget } from './ssh-target-draft'
applyParsedSshHostInput,
hasAdvancedConnectionValues,
type EditingTarget
} from './ssh-target-draft'
import { translate } from '@/i18n/i18n'
export { EMPTY_FORM, type EditingTarget } from './ssh-target-draft'
@@ -27,9 +27,10 @@ export function SshTargetForm({
onSave,
onCancel
}: SshTargetFormProps): React.JSX.Element {
// Why: reveal Advanced by default when editing a target that already has custom proxy/jump/
// reuse values, so those aren't hidden; otherwise it starts collapsed.
const hasAdvancedConnectionFields = hasAdvancedConnectionValues(form)
const [advancedConnectionOpen, setAdvancedConnectionOpen] = useState(hasAdvancedConnectionFields)
const [terminalPersistenceOpen, setTerminalPersistenceOpen] = useState(true)
const [advancedOpen, setAdvancedOpen] = useState(hasAdvancedConnectionFields)
const lastEditingIdRef = useRef(editingId)
useEffect(() => {
@@ -37,8 +38,7 @@ export function SshTargetForm({
return
}
lastEditingIdRef.current = editingId
setAdvancedConnectionOpen(hasAdvancedConnectionFields)
setTerminalPersistenceOpen(true)
setAdvancedOpen(hasAdvancedConnectionFields)
}, [editingId, hasAdvancedConnectionFields])
return (
@@ -122,16 +122,11 @@ export function SshTargetForm({
)}
</p>
</div>
<SshTargetAdvancedConnectionSection
open={advancedConnectionOpen}
onOpenChange={setAdvancedConnectionOpen}
form={form}
onFormChange={onFormChange}
/>
<SshTargetTerminalPersistenceSection
open={terminalPersistenceOpen}
onOpenChange={setTerminalPersistenceOpen}
<SshHostAdvancedFields
open={advancedOpen}
onOpenChange={setAdvancedOpen}
form={form}
disabled={false}
onFormChange={onFormChange}
/>
</div>
@@ -1,50 +0,0 @@
import { ChevronDown } from 'lucide-react'
import { Button } from '../ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
import { cn } from '@/lib/utils'
type SshTargetFormCollapsibleSectionProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
children: React.ReactNode
}
export function SshTargetFormCollapsibleSection({
open,
onOpenChange,
title,
description,
children
}: SshTargetFormCollapsibleSectionProps): React.JSX.Element {
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
className="col-span-2 border-t border-border/50 pt-2"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="-ml-2 h-auto w-[calc(100%+0.5rem)] justify-between gap-3 px-2 py-1.5 text-left"
>
<span className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium text-foreground">{title}</span>
<span className="block text-[11px] font-normal text-muted-foreground">
{description}
</span>
</span>
<ChevronDown
className={cn('size-4 shrink-0 transition-transform', open && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="collapsible-height-content">
<div className="space-y-4 pt-3">{children}</div>
</CollapsibleContent>
</Collapsible>
)
}
@@ -1,98 +0,0 @@
import {
DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS,
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS
} from '../../../../shared/ssh-types'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { SettingsSwitch } from './SettingsFormControls'
import { SshTargetFormCollapsibleSection } from './SshTargetFormCollapsibleSection'
import type { EditingTarget } from './ssh-target-draft'
import { translate } from '@/i18n/i18n'
type SshTargetTerminalPersistenceSectionProps = {
open: boolean
onOpenChange: (open: boolean) => void
form: EditingTarget
onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void
}
export function SshTargetTerminalPersistenceSection({
open,
onOpenChange,
form,
onFormChange
}: SshTargetTerminalPersistenceSectionProps): React.JSX.Element {
return (
<SshTargetFormCollapsibleSection
open={open}
onOpenChange={onOpenChange}
title={translate(
'auto.components.settings.SshTargetForm.92f80edbfd',
'Remote Terminal Persistence'
)}
description={translate(
'auto.components.settings.SshTargetForm.137e88ce8d',
'Remote terminals keep running after Orca disconnects from this host.'
)}
>
<div className="flex items-start justify-between gap-4 py-1 text-xs">
<div className="min-w-0 flex-1 space-y-0.5">
<Label className="text-xs font-medium">
{translate(
'auto.components.settings.SshTargetForm.71fc546097',
'Keep terminals alive until reset'
)}
</Label>
<p className="text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.b574994adc',
'Use End Remote Terminals or Reset Relay when you want to stop them.'
)}
</p>
</div>
<SettingsSwitch
checked={form.relayKeepAliveUntilReset}
onChange={() =>
onFormChange((f) => ({
...f,
relayKeepAliveUntilReset: !f.relayKeepAliveUntilReset
}))
}
ariaLabel={translate(
'auto.components.settings.SshTargetForm.71fc546097',
'Keep terminals alive until reset'
)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ssh-relay-grace-period" className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.55c56cf2c7',
'Timeout after disconnect (seconds)'
)}
</Label>
<Input
id="ssh-relay-grace-period"
type={form.relayKeepAliveUntilReset ? 'text' : 'number'}
value={
form.relayKeepAliveUntilReset
? translate('auto.components.settings.SshTargetForm.7c13f58c91', 'Until reset')
: form.relayGracePeriodSeconds
}
onChange={(e) => onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))}
placeholder={String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS)}
min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS}
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
disabled={form.relayKeepAliveUntilReset}
/>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SshTargetForm.1b19b00e93',
'Bounded timeouts must be between 60 seconds and 7 days.'
)}
</p>
</div>
</SshTargetFormCollapsibleSection>
)
}
@@ -137,6 +137,14 @@ export function getSshTargetDraftConnectionFields(draft: EditingTarget): {
}
}
export function hasAdvancedConnectionValues(form: EditingTarget): boolean {
return (
form.proxyCommand.trim().length > 0 ||
form.jumpHost.trim().length > 0 ||
!form.systemSshConnectionReuse
)
}
export function parseRelayGracePeriodSeconds(draft: EditingTarget): number {
return draft.relayKeepAliveUntilReset ? 0 : Number.parseInt(draft.relayGracePeriodSeconds, 10)
}
@@ -33,6 +33,13 @@ export function AddRemoteHostDialog({
onOpenChange
}: AddRemoteHostDialogProps): React.JSX.Element {
const open = mode !== null
// Why: `mode` drives both open-state and which form renders. On close it goes null while the
// dialog is still animating out, so the title/fields would flash to the SSH default. Latch the
// last non-null mode for rendering so the closing dialog keeps showing what the user saw.
const [renderMode, setRenderMode] = useState<AddRemoteHostMode>(mode ?? 'ssh')
if (mode !== null && mode !== renderMode) {
setRenderMode(mode)
}
const [sshForm, setSshForm] = useState<EditingTarget>(EMPTY_FORM)
const [serverName, setServerName] = useState('')
const [pairingCode, setPairingCode] = useState('')
@@ -219,7 +226,7 @@ export function AddRemoteHostDialog({
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{mode === 'server'
{renderMode === 'server'
? translate(
'auto.components.sidebar.AddRemoteHostDialog.serverTitle',
'Add remote server'
@@ -227,7 +234,7 @@ export function AddRemoteHostDialog({
: translate('auto.components.sidebar.AddRemoteHostDialog.sshTitle', 'Add SSH host')}
</DialogTitle>
<DialogDescription>
{mode === 'server'
{renderMode === 'server'
? translate(
'auto.components.sidebar.AddRemoteHostDialog.serverDescription',
'Pair with Orca running on another computer.'
@@ -239,7 +246,7 @@ export function AddRemoteHostDialog({
</DialogDescription>
</DialogHeader>
{mode === 'server' ? (
{renderMode === 'server' ? (
<RemoteServerFields
name={serverName}
pairingCode={pairingCode}
@@ -258,7 +265,7 @@ export function AddRemoteHostDialog({
)}
<DialogFooter className="sm:justify-between">
{mode === 'ssh' ? (
{renderMode === 'ssh' ? (
<button
type="button"
className="self-center text-left text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
@@ -286,7 +293,9 @@ export function AddRemoteHostDialog({
</Button>
<Button
type="button"
onClick={mode === 'server' ? () => void saveRemoteServer() : () => void saveSshHost()}
onClick={
renderMode === 'server' ? () => void saveRemoteServer() : () => void saveSshHost()
}
disabled={isSaving || isImporting}
>
{isSaving
@@ -1,7 +1,9 @@
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { translate } from '@/i18n/i18n'
import { applyParsedSshHostInput, type EditingTarget } from '../settings/ssh-target-draft'
import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields'
export function SshHostFields({
form,
@@ -14,6 +16,7 @@ export function SshHostFields({
onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void
onSubmit: () => void
}) {
const [advancedOpen, setAdvancedOpen] = useState(false)
return (
<form
className="grid gap-3 sm:grid-cols-2"
@@ -103,12 +106,13 @@ export function SshHostFields({
)}
/>
</div>
<p className="text-xs text-muted-foreground sm:col-span-2">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.sshPersistenceDefault',
'Remote terminals on this host stay alive until you end them or reset the relay.'
)}
</p>
<SshHostAdvancedFields
open={advancedOpen}
onOpenChange={setAdvancedOpen}
form={form}
disabled={disabled}
onFormChange={onFormChange}
/>
</form>
)
}
@@ -1,5 +1,6 @@
import { LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translate } from '@/i18n/i18n'
import type { SparsePresetDirectoryParseResult } from '@/lib/sparse-preset-draft'
@@ -34,7 +35,10 @@ export function SparseCheckoutPresetDraftForm({
onSave
}: SparseCheckoutPresetDraftFormProps): React.JSX.Element {
return (
// Why: bg-popover matches the opaque surface the cmdk list (Command root) paints, so the draft
// form doesn't show the translucent PopoverContent through it while the list looks solid.
<form
className="rounded-md bg-popover text-popover-foreground"
onSubmit={(event) => {
event.preventDefault()
onSave()
@@ -56,22 +60,20 @@ export function SparseCheckoutPresetDraftForm({
>
{translate('auto.components.sparse.SparseCheckoutPresetSelect.b3a500c623', 'Name')}
</label>
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
<input
id="sparse-preset-name"
ref={setNameInputNode}
value={draft.name}
onChange={(event) => onDraftChange({ ...draft, name: event.target.value })}
placeholder={translate(
'auto.components.sparse.SparseCheckoutPresetSelect.064c1e2d12',
'Renderer UI'
)}
maxLength={80}
autoComplete="off"
spellCheck={false}
className="h-8 w-full bg-transparent text-xs text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
/>
</div>
<Input
id="sparse-preset-name"
ref={setNameInputNode}
value={draft.name}
onChange={(event) => onDraftChange({ ...draft, name: event.target.value })}
placeholder={translate(
'auto.components.sparse.SparseCheckoutPresetSelect.064c1e2d12',
'Renderer UI'
)}
maxLength={80}
autoComplete="off"
spellCheck={false}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label
@@ -83,20 +85,20 @@ export function SparseCheckoutPresetDraftForm({
'Directories'
)}
</label>
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
<textarea
id="sparse-preset-directories"
value={draft.directoriesText}
onChange={(event) => onDraftChange({ ...draft, directoriesText: event.target.value })}
placeholder={translate(
'auto.components.sparse.SparseCheckoutPresetSelect.ddbcaef7be',
'src/renderer packages/ui'
)}
rows={3}
spellCheck={false}
className="max-h-28 w-full min-w-0 resize-none bg-transparent font-mono text-xs leading-5 text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
/>
</div>
{/* Why: match the app's canonical textarea (composer Note field) — border-input +
standard focus ring — instead of a one-off wrapper. font-mono since these are paths. */}
<textarea
id="sparse-preset-directories"
value={draft.directoriesText}
onChange={(event) => onDraftChange({ ...draft, directoriesText: event.target.value })}
placeholder={translate(
'auto.components.sparse.SparseCheckoutPresetSelect.ddbcaef7be',
'src/renderer packages/ui'
)}
rows={3}
spellCheck={false}
className="max-h-28 w-full min-w-0 resize-none rounded-md border border-input bg-transparent px-3 py-1.5 font-mono text-xs leading-5 shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
</div>
<div className="flex min-h-11 items-center justify-between gap-3 border-t border-border px-3 py-2">
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Check, ChevronsUpDown, LoaderCircle, Pencil, Plus, RefreshCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Command, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
@@ -225,7 +226,7 @@ export default function SparseCheckoutPresetSelect({
aria-expanded={open}
aria-busy={isLoadingPresets}
disabled={disabled || isLoadingPresets}
className="h-9 w-full justify-between px-3 text-sm font-normal text-foreground"
className="h-9 w-full justify-between border-input px-3 text-sm font-normal text-foreground focus:border-ring focus:ring-[3px] focus:ring-ring/50"
>
<span className="truncate">{triggerLabel}</span>
{isLoadingPresets ? (
@@ -281,39 +282,37 @@ export default function SparseCheckoutPresetSelect({
</button>
</div>
) : (
<div>
<div className="py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-0.5rem)] items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground"
onClick={handleSelectOff}
// Why: cmdk Command/CommandItem so this dropdown matches the other composer pickers
// (run-target, project, agent) — same padding, keyboard-highlight, and check placement.
<Command value={selectedPreset ? `preset:${selectedPreset.id}` : 'off'}>
<CommandList>
<CommandItem
value="off"
onSelect={handleSelectOff}
className="items-center gap-2 px-3 py-2"
>
<Check className={cn('size-4', selectedPreset ? 'opacity-0' : 'opacity-100')} />
{translate('auto.components.sparse.SparseCheckoutPresetSelect.c7f9b3f0c1', 'Off')}
</button>
</div>
{visiblePresets.length > 0 ? (
<>
<div className="h-px bg-border" />
<div className="space-y-0.5 py-1">
<span className="truncate">
{translate('auto.components.sparse.SparseCheckoutPresetSelect.c7f9b3f0c1', 'Off')}
</span>
</CommandItem>
{visiblePresets.length > 0 ? (
<>
<CommandSeparator />
{visiblePresets.map((preset) => (
<div
<CommandItem
key={preset.id}
className="mx-1 flex items-center rounded-md hover:bg-accent hover:text-accent-foreground"
value={`preset:${preset.id}`}
onSelect={() => handleSelectPreset(preset)}
className="items-center gap-2 px-3 py-2"
>
<button
type="button"
className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-l-md px-2 text-left text-xs"
onClick={() => handleSelectPreset(preset)}
>
<Check
className={cn(
'size-4 shrink-0',
selectedPreset?.id === preset.id ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="truncate">{preset.name}</span>
</button>
<Check
className={cn(
'size-4 shrink-0',
selectedPreset?.id === preset.id ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="min-w-0 flex-1 truncate">{preset.name}</span>
<Button
type="button"
variant="ghost"
@@ -323,31 +322,41 @@ export default function SparseCheckoutPresetSelect({
'Edit {{value0}}',
{ value0: preset.name }
)}
className="mr-1 size-7 shrink-0 rounded-md text-muted-foreground hover:bg-background/35 hover:text-foreground"
onClick={() => startEditPreset(preset)}
className="ml-1 size-6 shrink-0 rounded-md text-muted-foreground hover:bg-background/35 hover:text-foreground"
// Why: the pencil opens the edit draft; stop the event so cmdk doesn't
// also select the preset row underneath it.
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
startEditPreset(preset)
}}
>
<Pencil className="size-3.5" />
</Button>
</div>
</CommandItem>
))}
</div>
</>
) : null}
<div className="border-t border-border">
<Button
type="button"
variant="ghost"
onClick={startNewPreset}
className="mx-1 my-1 h-8 w-[calc(100%-0.5rem)] justify-start rounded-md px-2 text-xs font-normal"
</>
) : null}
<CommandSeparator />
<CommandItem
value="new-preset"
onSelect={startNewPreset}
className="items-center gap-2 px-3 py-2 text-muted-foreground"
>
<Plus className="size-3.5 text-muted-foreground" />
{translate(
'auto.components.sparse.SparseCheckoutPresetSelect.c4ac80151d',
'New preset'
)}
</Button>
</div>
</div>
<Plus className="size-4 shrink-0" />
<span className="truncate">
{translate(
'auto.components.sparse.SparseCheckoutPresetSelect.c4ac80151d',
'New preset'
)}
</span>
</CommandItem>
</CommandList>
</Command>
)}
</PopoverContent>
</Popover>
+15 -7
View File
@@ -1256,15 +1256,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const selectedRepoSettingsRef = useRef(selectedRepoSettings)
selectedRepoSettingsRef.current = selectedRepoSettings
// Why: depend on the persisted policy *value*, not the selectedRepo object. Background repo
// refetches (git polling) hand back a new repo reference with the same hookSettings; keying on
// the object would re-run this and briefly flip the toggle back to the stale value — the glitch.
const persistedSetupAgentStartupPolicy = getRepoSetupAgentStartupPolicy(selectedRepo)
useEffect(() => {
const nextPolicy = getRepoSetupAgentStartupPolicy(selectedRepo)
const draft = setupAgentStartupPolicyDraftRef.current
if (draft?.repoId === repoId && draft.policy !== nextPolicy) {
if (draft?.repoId === repoId && draft.policy !== persistedSetupAgentStartupPolicy) {
return
}
setupAgentStartupPolicyRef.current = nextPolicy
setSetupAgentStartupPolicy(nextPolicy)
}, [repoId, selectedRepo])
setupAgentStartupPolicyRef.current = persistedSetupAgentStartupPolicy
setSetupAgentStartupPolicy(persistedSetupAgentStartupPolicy)
}, [repoId, persistedSetupAgentStartupPolicy])
const persistSetupAgentStartupPolicy = useCallback(
async (
@@ -1776,7 +1779,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}
void readRuntimeIssueCommand(selectedRepoSettings, repoId)
void readRuntimeIssueCommand(selectedRepoSettingsRef.current, repoId)
.then((result) => {
if (!cancelled) {
setIssueCommandTemplate(result.effectiveContent ?? '')
@@ -1793,13 +1796,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
// Why: key on the stable runtime-env id, not the selectedRepoSettings object. `updateRepo`
// (e.g. saving the setup toggle from this very composer) replaces selectedRepo — and thus the
// memoized selectedRepoSettings — by reference; depending on the object would re-run this
// effect, blank yamlHooks to null, and make the whole setup section vanish for a frame.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
commitHookCheckIfCurrent,
enableIssueAutomation,
loadHookCheckForRepo,
repoId,
selectedRepoIsGit,
selectedRepoSettings
runtimeEnvironmentId
])
const onConnectSelectedRepo = useCallback(async (): Promise<void> => {
@@ -243,6 +243,21 @@ describe('buildProjectHostSetupOptions', () => {
}
)
it.each([
['connecting' as const, false],
['disconnected' as const, false],
['error' as const, true]
])('flags only errored %s hosts for attention', (health: ExecutionHostHealth, attention) => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [host('local'), host('ssh:builder', { label: 'Builder', health })],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options.at(-1)).toMatchObject({ kind: 'needs-setup', attention })
})
it.each([
['ssh:builder' as const, { kind: 'ssh', targetId: 'builder' }],
['runtime:gpu' as const, { kind: 'runtime', environmentId: 'gpu' }]
@@ -32,6 +32,9 @@ export type ProjectHostSetupOption =
label: string
detail: string
isAvailable: boolean
// Why: only a genuine connection error warrants an alarm glyph; a dormant
// disconnected host is merely not-yet-connected, not broken.
attention: boolean
connectAction?: { kind: 'ssh'; targetId: string } | { kind: 'runtime'; environmentId: string }
}
@@ -164,6 +167,7 @@ function buildNeedsSetupOptions({
: 'Project not set up on this host'
: availability.detail,
isAvailable: availability.isAvailable,
attention: host.health === 'error',
...(connectAction ? { connectAction } : {})
}
})