mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user