From ee1f456e3a110bb523203596f09ab43e759ce455 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 9 Jul 2026 02:32:57 -0400 Subject: [PATCH] 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. --- .../NewWorkspaceComposerCard.test.tsx | 148 +++++- .../components/NewWorkspaceComposerCard.tsx | 462 ++++++++++-------- .../components/NewWorkspaceComposerModal.tsx | 9 +- .../ProjectHostSetupCombobox.test.tsx | 6 +- .../settings/SshHostAdvancedFields.tsx | 178 +++++++ .../SshTargetAdvancedConnectionSection.tsx | 106 ---- .../src/components/settings/SshTargetForm.tsx | 31 +- .../SshTargetFormCollapsibleSection.tsx | 50 -- .../SshTargetTerminalPersistenceSection.tsx | 98 ---- .../components/settings/ssh-target-draft.ts | 8 + .../sidebar/AddRemoteHostDialog.tsx | 19 +- .../sidebar/AddRemoteHostFields.tsx | 16 +- .../sparse/SparseCheckoutPresetDraftForm.tsx | 62 +-- .../sparse/SparseCheckoutPresetSelect.tsx | 107 ++-- src/renderer/src/hooks/useComposerState.ts | 22 +- .../lib/project-host-setup-options.test.ts | 15 + .../src/lib/project-host-setup-options.ts | 4 + 17 files changed, 745 insertions(+), 596 deletions(-) create mode 100644 src/renderer/src/components/settings/SshHostAdvancedFields.tsx delete mode 100644 src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx delete mode 100644 src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx delete mode 100644 src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index c639082d581..874708be52f 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -56,6 +56,13 @@ vi.mock('@/components/agent/AgentCombobox', () => ({ default: () => })) +// 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 ?
: null +})) + vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ default: () =>
})) @@ -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> = {} ) { @@ -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( + const waitSwitch = current.container.querySelector( '[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( + '[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', () => { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index f384b0e9051..21549bab73c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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(promise: Promise): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, 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(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>( + () => 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 => { - 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.' )} - {hostOptions.map((option) => - option.kind === 'ready' ? ( - handleHostSelect(option.id)} - className="items-center gap-2 px-3 py-2" - > - - -
-
{option.label}
-
- {option.path} -
+ {readyHostOptions.map((option) => ( + handleHostSelect(option.id)} + onPointerEnter={closeSubmenus} + onFocus={closeSubmenus} + className="items-center gap-2 px-3 py-2" + > + + +
+
{option.label}
+
+ {option.path}
- - ) : ( - // Why: setup-on-host is a follow-up; this row only explains why the host cannot - // run the workspace yet. - -
- - {option.isAvailable ? ( - - ) : ( - - )} -
-
{option.label}
-
- {option.detail} +
+ + ))} + {/* 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 ? ( + <> + + {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. + {}} + onPointerEnter={closeSubmenus} + onFocus={closeSubmenus} + className="items-center gap-2 px-3 py-2" + > +
+ + {/* Why: reserve the alarm glyph for a genuine connection error; a dormant + disconnected host gets the neutral server icon. */} + {option.attention ? ( + + ) : ( + + )} +
+
{option.label}
+
+ {option.detail} +
-
- {option.connectAction && onConnectHost ? ( - - ) : null} - - ) - )} + {option.connectAction && onConnectHost ? ( + + ) : null} + + ))} + + ) : 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 ? ( + + ) : null} {recipes.length > 0 ? ( @@ -455,7 +551,9 @@ function WorkspaceRunTargetCombobox({ only rendering under the first host, the uneven height, and the double-highlight. */} setVmRecipesOpen(true)} + onSelect={openVmRecipesSubmenu} + onPointerEnter={openVmRecipesSubmenu} + onFocus={openVmRecipesSubmenu} className="items-center gap-2 px-3 py-2" > setHostActionsOpen(true)} + onSelect={openHostActionsSubmenu} + onPointerEnter={openHostActionsSubmenu} + onFocus={openHostActionsSubmenu} className="items-center gap-2 px-3 py-2" > @@ -538,7 +638,9 @@ function WorkspaceRunTargetCombobox({ - + {/* 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. */} + -
-
- {translate('auto.components.NewWorkspaceComposerCard.23bb365554', 'orca.yaml')} -
- {headerAction} -
- {/* Why: long orca.yaml scripts must not grow the create dialog past the viewport. */} -
-          {setupConfig.command}
-        
-
- ) - } - +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 ( -
-
-
- {setupConfig.source === 'both' - ? translate( - 'auto.components.NewWorkspaceComposerCard.e5db1b0419', - 'Combined setup command' - ) - : translate( - 'auto.components.NewWorkspaceComposerCard.7711ad5122', - 'Local setup command' - )} -
- {headerAction} -
-
+    
+
         {setupConfig.command}
       
@@ -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(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 => { 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. */}
+ {/* 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. */}
) : null} @@ -1685,6 +1747,10 @@ export default function NewWorkspaceComposerCard({
+ {/* 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. */} +
) } diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 3bbac8e9676..d9753ff1543 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -258,10 +258,11 @@ function QuickTabBody({ { diff --git a/src/renderer/src/components/settings/SshHostAdvancedFields.tsx b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx new file mode 100644 index 00000000000..f8f4d72a6b8 --- /dev/null +++ b/src/renderer/src/components/settings/SshHostAdvancedFields.tsx @@ -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 ( + + + {/* 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. */} + + + +
+
+ + onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))} + placeholder={translate( + 'auto.components.settings.SshTargetForm.f42d844544', + 'e.g. cloudflared access ssh --hostname %h' + )} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.3b01ca44a0', + 'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).' + )} +

+
+
+ + onFormChange((f) => ({ ...f, jumpHost: e.target.value }))} + placeholder={translate( + 'auto.components.settings.SshTargetForm.11bcb4507a', + 'bastion.example.com' + )} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.feae1d1e69', + 'Optional. Equivalent to ProxyJump / ssh -J.' + )} +

+
+
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.53e9aabfc0', + 'Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + systemSshConnectionReuse: !f.systemSshConnectionReuse + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.8c922dffba', + 'Reuse SSH connection for faster setup' + )} + /> +
+
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.b574994adc', + 'Use End Remote Terminals or Reset Relay when you want to stop them.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + relayKeepAliveUntilReset: !f.relayKeepAliveUntilReset + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.71fc546097', + 'Keep terminals alive until reset' + )} + /> +
+
+ + + 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} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.1b19b00e93', + 'Bounded timeouts must be between 60 seconds and 7 days.' + )} +

+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx b/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx deleted file mode 100644 index 0bdbd3d3505..00000000000 --- a/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx +++ /dev/null @@ -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 ( - -
- - onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))} - placeholder={translate( - 'auto.components.settings.SshTargetForm.f42d844544', - 'e.g. cloudflared access ssh --hostname %h' - )} - /> -

- {translate( - 'auto.components.settings.SshTargetForm.3b01ca44a0', - 'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).' - )} -

-
-
- - onFormChange((f) => ({ ...f, jumpHost: e.target.value }))} - placeholder={translate( - 'auto.components.settings.SshTargetForm.11bcb4507a', - 'bastion.example.com' - )} - /> -

- {translate( - 'auto.components.settings.SshTargetForm.feae1d1e69', - 'Optional. Equivalent to ProxyJump / ssh -J.' - )} -

-
-
-
- -

- {translate( - 'auto.components.settings.SshTargetForm.53e9aabfc0', - 'Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.' - )} -

-
- - onFormChange((f) => ({ - ...f, - systemSshConnectionReuse: !f.systemSshConnectionReuse - })) - } - ariaLabel={translate( - 'auto.components.settings.SshTargetForm.8c922dffba', - 'Reuse SSH connection for faster setup' - )} - /> -
-
- ) -} - -export function hasAdvancedConnectionValues(form: EditingTarget): boolean { - return ( - form.proxyCommand.trim().length > 0 || - form.jumpHost.trim().length > 0 || - !form.systemSshConnectionReuse - ) -} diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx index fbba5918e50..cec3d8b8b7c 100644 --- a/src/renderer/src/components/settings/SshTargetForm.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.tsx @@ -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({ )}

- -
diff --git a/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx b/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx deleted file mode 100644 index 13d9b3e784e..00000000000 --- a/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx +++ /dev/null @@ -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 ( - - - - - -
{children}
-
-
- ) -} diff --git a/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx b/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx deleted file mode 100644 index 0306bc3d3d8..00000000000 --- a/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx +++ /dev/null @@ -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 ( - -
-
- -

- {translate( - 'auto.components.settings.SshTargetForm.b574994adc', - 'Use End Remote Terminals or Reset Relay when you want to stop them.' - )} -

-
- - onFormChange((f) => ({ - ...f, - relayKeepAliveUntilReset: !f.relayKeepAliveUntilReset - })) - } - ariaLabel={translate( - 'auto.components.settings.SshTargetForm.71fc546097', - 'Keep terminals alive until reset' - )} - /> -
-
- - 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} - /> -

- {translate( - 'auto.components.settings.SshTargetForm.1b19b00e93', - 'Bounded timeouts must be between 60 seconds and 7 days.' - )} -

-
-
- ) -} diff --git a/src/renderer/src/components/settings/ssh-target-draft.ts b/src/renderer/src/components/settings/ssh-target-draft.ts index 2719f62add3..b9100232d16 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.ts @@ -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) } diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index c6b7870381f..65bd5672d13 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -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(mode ?? 'ssh') + if (mode !== null && mode !== renderMode) { + setRenderMode(mode) + } const [sshForm, setSshForm] = useState(EMPTY_FORM) const [serverName, setServerName] = useState('') const [pairingCode, setPairingCode] = useState('') @@ -219,7 +226,7 @@ export function AddRemoteHostDialog({ - {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')} - {mode === 'server' + {renderMode === 'server' ? translate( 'auto.components.sidebar.AddRemoteHostDialog.serverDescription', 'Pair with Orca running on another computer.' @@ -239,7 +246,7 @@ export function AddRemoteHostDialog({ - {mode === 'server' ? ( + {renderMode === 'server' ? ( - {mode === 'ssh' ? ( + {renderMode === 'ssh' ? (
-

- {translate( - 'auto.components.sidebar.AddRemoteHostDialog.sshPersistenceDefault', - 'Remote terminals on this host stay alive until you end them or reset the relay.' - )} -

+ ) } diff --git a/src/renderer/src/components/sparse/SparseCheckoutPresetDraftForm.tsx b/src/renderer/src/components/sparse/SparseCheckoutPresetDraftForm.tsx index 8920b14cbb2..b257f7cf923 100644 --- a/src/renderer/src/components/sparse/SparseCheckoutPresetDraftForm.tsx +++ b/src/renderer/src/components/sparse/SparseCheckoutPresetDraftForm.tsx @@ -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.
{ event.preventDefault() onSave() @@ -56,22 +60,20 @@ export function SparseCheckoutPresetDraftForm({ > {translate('auto.components.sparse.SparseCheckoutPresetSelect.b3a500c623', 'Name')} -
- 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" - /> -
+ 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" + />