+ group.entries.map((entry) => ({
+ id: entry.stableKey,
+ label: entry.label,
+ group: group.authorityLabel,
+ disabled: !automationCreateHostEligible(entry),
+ content: (
+
+
+
+
+ )
+ }))
+ )}
+ />
{selected && control.projects.length === 0 ? (
// Otherwise Create is disabled for an empty project list with nothing said.
diff --git a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx
index e9b2c2cab02..201f19dd51f 100644
--- a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx
+++ b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx
@@ -19,6 +19,9 @@ import {
import { AgentIcon, getAgentCatalog } from '@/lib/agent-catalog'
import { searchAgentPickerEntries } from '@/lib/agent-picker-search'
import { translate } from '@/i18n/i18n'
+import { useBoundedHostRows } from '@/components/host-picker/use-bounded-host-rows'
+import { HostListSearchField } from '@/components/host-picker/HostListSearchField'
+import { HostListOverflowNote } from '@/components/host-picker/HostListOverflowNote'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import {
countAutomationListFilters,
@@ -138,6 +141,41 @@ export function AutomationListFilterPills({
)
}
+/** One row per catalog host, bounded and searchable; selected hosts always stay listed. */
+function AutomationHostFilterRows({
+ entries,
+ selectedHostKeys,
+ onToggle
+}: {
+ entries: readonly AutomationHostCatalogEntry[]
+ selectedHostKeys: readonly string[]
+ onToggle: (stableKey: string) => void
+}): React.JSX.Element {
+ const selected = new Set(selectedHostKeys)
+ const rows = useBoundedHostRows(
+ entries.map((entry) => ({ id: entry.stableKey, label: entry.label })),
+ selected
+ )
+ return (
+ <>
+ {rows.searchable ? (
+
+ ) : null}
+ {rows.shown.map((row) => (
+ onToggle(row.id)}
+ onSelect={(event) => event.preventDefault()}
+ >
+ {row.label}
+
+ ))}
+
+ >
+ )
+}
+
export function AutomationListFilterMenu({
filter,
onChange,
@@ -211,16 +249,11 @@ export function AutomationListFilterMenu({
>
{translate('auto.components.automations.hostPicker.allHosts', 'All hosts')}
- {hostEntries.map((entry) => (
- toggleHost(entry.stableKey)}
- onSelect={(event) => event.preventDefault()}
- >
- {entry.label}
-
- ))}
+
diff --git a/src/renderer/src/components/host-picker/HostCombobox.tsx b/src/renderer/src/components/host-picker/HostCombobox.tsx
new file mode 100644
index 00000000000..4c52f36ff52
--- /dev/null
+++ b/src/renderer/src/components/host-picker/HostCombobox.tsx
@@ -0,0 +1,149 @@
+import { useState } from 'react'
+import { Check, ChevronDown } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList
+} from '@/components/ui/command'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+import { boundHostList } from './bounded-host-list'
+import { HostListOverflowNote } from './HostListOverflowNote'
+import { noHostsMatchLabel, searchHostsLabel } from './host-picker-copy'
+
+export type HostComboboxOption = {
+ id: string
+ /** What search matches and the trigger shows. */
+ label: string
+ /** Secondary text after the label, such as "SSH" or "disconnected". */
+ detail?: string
+ /** Replaces label and detail in the result row. */
+ content?: React.ReactNode
+ /** Heading the row is listed under; rows keep their input order within it. */
+ group?: string
+ disabled?: boolean
+}
+
+function groupOptions(
+ options: readonly HostComboboxOption[]
+): { heading: string | undefined; options: HostComboboxOption[] }[] {
+ const groups = new Map()
+ for (const option of options) {
+ const group = groups.get(option.group) ?? []
+ group.push(option)
+ groups.set(option.group, group)
+ }
+ return [...groups].map(([heading, grouped]) => ({ heading, options: grouped }))
+}
+
+/**
+ * Single choice among hosts. Replaces a Select, which mounts an item per option even while closed:
+ * this mounts nothing until opened, then a bounded, searchable result list.
+ */
+export function HostCombobox({
+ id,
+ value,
+ onChange,
+ options,
+ placeholder,
+ ariaLabel,
+ size = 'default',
+ className,
+ disabled
+}: {
+ id?: string
+ value: string
+ onChange: (id: string) => void
+ options: readonly HostComboboxOption[]
+ placeholder?: string
+ ariaLabel?: string
+ size?: 'xs' | 'sm' | 'default'
+ className?: string
+ disabled?: boolean
+}): React.JSX.Element {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const selected = options.find((option) => option.id === value)
+ // Why pinned only before typing: the current choice stays visible without crowding results.
+ const { shown, hiddenCount } = open
+ ? boundHostList(options, { query, pinnedIds: query === '' ? new Set([value]) : undefined })
+ : { shown: [], hiddenCount: 0 }
+
+ return (
+ {
+ setOpen(next)
+ if (next) {
+ setQuery('')
+ }
+ }}
+ >
+
+
+
+
+
+
+
+ {noHostsMatchLabel()}
+ {groupOptions(shown).map((group) => (
+
+ {group.options.map((option) => (
+ {
+ setOpen(false)
+ // Why: like a Select, re-picking the current host is not a change.
+ if (option.id !== value) {
+ onChange(option.id)
+ }
+ }}
+ >
+ {option.content ?? (
+ <>
+ {option.label}
+ {option.detail ? (
+ {option.detail}
+ ) : null}
+ >
+ )}
+ {option.id === value ? : null}
+
+ ))}
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/host-picker/HostListOverflowNote.tsx b/src/renderer/src/components/host-picker/HostListOverflowNote.tsx
new file mode 100644
index 00000000000..bd0f544614b
--- /dev/null
+++ b/src/renderer/src/components/host-picker/HostListOverflowNote.tsx
@@ -0,0 +1,17 @@
+import { moreHostsLabel } from './host-picker-copy'
+
+/** Says how many hosts a bounded list left out and that searching reaches them. */
+export function HostListOverflowNote({
+ hiddenCount
+}: {
+ hiddenCount: number
+}): React.JSX.Element | null {
+ if (hiddenCount <= 0) {
+ return null
+ }
+ return (
+
+ {moreHostsLabel(hiddenCount)}
+
+ )
+}
diff --git a/src/renderer/src/components/host-picker/HostListSearchField.tsx b/src/renderer/src/components/host-picker/HostListSearchField.tsx
new file mode 100644
index 00000000000..39726ef10c2
--- /dev/null
+++ b/src/renderer/src/components/host-picker/HostListSearchField.tsx
@@ -0,0 +1,98 @@
+import { useRef } from 'react'
+import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
+import { Input } from '@/components/ui/input'
+import { searchHostsLabel } from './host-picker-copy'
+
+const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
+
+/** Moves focus from the search item to the menu item before or after it. */
+function focusAdjacentMenuItem(from: HTMLElement, step: 1 | -1): void {
+ const menu = from.closest('[role="menu"]')
+ const item = from.closest(MENU_ITEM_SELECTOR)
+ if (!menu || !item) {
+ return
+ }
+ const items = Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)).filter(
+ (candidate) => !candidate.hasAttribute('data-disabled')
+ )
+ items[items.indexOf(item) + step]?.focus()
+}
+
+function HostListSearchInput({
+ value,
+ onChange,
+ inputRef,
+ onKeyDown
+}: {
+ value: string
+ onChange: (value: string) => void
+ inputRef?: React.RefObject
+ onKeyDown?: React.KeyboardEventHandler
+}): React.JSX.Element {
+ return (
+ onChange(event.target.value)}
+ onKeyDown={onKeyDown}
+ placeholder={searchHostsLabel()}
+ aria-label={searchHostsLabel()}
+ className="h-7"
+ />
+ )
+}
+
+/**
+ * Search box for a bounded host checklist. Inside a dropdown menu it is a menu item, so arrow keys
+ * reach it like any row; focusing the item hands focus to the input, and arrows leave it again.
+ */
+export function HostListSearchField({
+ value,
+ onChange,
+ inMenu = false
+}: {
+ value: string
+ onChange: (value: string) => void
+ inMenu?: boolean
+}): React.JSX.Element {
+ const inputRef = useRef(null)
+ if (!inMenu) {
+ return (
+
+
+
+ )
+ }
+ return (
+ event.preventDefault()}
+ onFocus={(event) => {
+ if (event.target === event.currentTarget) {
+ inputRef.current?.focus()
+ }
+ }}
+ >
+ {
+ if (event.nativeEvent.isComposing) {
+ event.stopPropagation()
+ return
+ }
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ event.stopPropagation()
+ focusAdjacentMenuItem(event.currentTarget, event.key === 'ArrowDown' ? 1 : -1)
+ return
+ }
+ // Why: letters, Space and Enter belong to the input, not the menu's typeahead or select.
+ if (event.key !== 'Escape' && event.key !== 'Tab') {
+ event.stopPropagation()
+ }
+ }}
+ />
+
+ )
+}
diff --git a/src/renderer/src/components/host-picker/bounded-host-list.test.tsx b/src/renderer/src/components/host-picker/bounded-host-list.test.tsx
new file mode 100644
index 00000000000..554531c08f3
--- /dev/null
+++ b/src/renderer/src/components/host-picker/bounded-host-list.test.tsx
@@ -0,0 +1,97 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem
+} from '@/components/ui/dropdown-menu'
+import { boundHostList } from './bounded-host-list'
+import { HostListSearchField } from './HostListSearchField'
+
+function host(label: string): { id: string; label: string } {
+ return { id: label, label }
+}
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('boundHostList', () => {
+ it('ranks an exact name first, then prefixes, then substrings or search text', () => {
+ const hosts = [
+ host('east-prod'),
+ host('prod-2'),
+ { ...host('db'), searchText: 'prod database' },
+ host('PROD')
+ ]
+ expect(boundHostList(hosts, { query: ' prod ' }).shown.map((h) => h.id)).toEqual([
+ 'PROD',
+ 'prod-2',
+ 'east-prod',
+ 'db'
+ ])
+ })
+
+ it('gives matches their own limit so a full pinned set never hides a search result', () => {
+ const hosts = Array.from({ length: 80 }, (_, i) => host(`h-${i}`))
+ const pinnedIds = new Set(hosts.slice(0, 60).map((h) => h.id))
+ const { shown, hiddenCount } = boundHostList(hosts, { query: 'h-79', pinnedIds, limit: 50 })
+ expect(shown.map((h) => h.id)).toEqual(['h-79'])
+ expect(hiddenCount).toBe(0)
+ const all = boundHostList(hosts, { pinnedIds, limit: 50 })
+ expect(all.shown).toHaveLength(70)
+ expect(all.hiddenCount).toBe(10)
+ })
+
+ it('ranks pinned matches too, so an exact pinned name survives a long pinned set', () => {
+ const hosts = [...Array.from({ length: 60 }, (_, i) => host(`node-${i}-prod`)), host('prod')]
+ const pinnedIds = new Set(hosts.map((h) => h.id))
+ const { shown } = boundHostList(hosts, { query: 'prod', pinnedIds, limit: 50 })
+ expect(shown[0]?.id).toBe('prod')
+ })
+
+ it('keeps input order when not searching, so a row never jumps when it is pinned', () => {
+ const { shown } = boundHostList([host('local'), host('a'), host('b')], {
+ pinnedIds: new Set(['b'])
+ })
+ expect(shown.map((h) => h.id)).toEqual(['local', 'a', 'b'])
+ })
+
+ it('narrows pinned hosts by the query too', () => {
+ const { shown } = boundHostList([host('a1'), host('b1')], {
+ query: 'b',
+ pinnedIds: new Set(['a1'])
+ })
+ expect(shown.map((h) => h.id)).toEqual(['b1'])
+ })
+})
+
+describe('HostListSearchField in a menu', () => {
+ it('is reached and left with arrow keys, and keeps letters out of the menu', () => {
+ const onChange = vi.fn()
+ render(
+
+
+ All hosts
+
+ node-1
+
+
+ )
+ const search = screen.getByRole('textbox', { name: 'Search hosts…' })
+ const searchItem = search.closest('[role="menuitem"]')
+ expect(searchItem).toBeTruthy()
+ fireEvent.focus(searchItem!)
+ expect(document.activeElement).toBe(search)
+ fireEvent.keyDown(search, { key: 'n' })
+ fireEvent.change(search, { target: { value: 'n' } })
+ expect(onChange).toHaveBeenCalledWith('n')
+ fireEvent.keyDown(search, { key: 'ArrowDown' })
+ expect(document.activeElement?.textContent).toBe('node-1')
+ search.focus()
+ fireEvent.keyDown(search, { key: 'ArrowUp' })
+ expect(document.activeElement?.textContent).toBe('All hosts')
+ })
+})
diff --git a/src/renderer/src/components/host-picker/bounded-host-list.ts b/src/renderer/src/components/host-picker/bounded-host-list.ts
new file mode 100644
index 00000000000..9a9dba92827
--- /dev/null
+++ b/src/renderer/src/components/host-picker/bounded-host-list.ts
@@ -0,0 +1,54 @@
+// Why: a generated ~/.ssh/config can import >10k hosts; a row each made one list cost GBs of DOM.
+export const HOST_LIST_ROW_LIMIT = 50
+
+export type BoundedHostList = {
+ shown: T[]
+ /** Hosts matching the query that the limits left out. */
+ hiddenCount: number
+}
+
+/** 0 exact name, 1 name prefix, 2 substring of the name or `searchText`; null for no match. */
+function matchRank(host: { label: string; searchText?: string }, needle: string): number | null {
+ const label = host.label.toLowerCase()
+ if (label === needle) {
+ return 0
+ }
+ if (label.startsWith(needle)) {
+ return 1
+ }
+ if (label.includes(needle) || host.searchText?.toLowerCase().includes(needle)) {
+ return 2
+ }
+ return null
+}
+
+/**
+ * The one choke point for drawing a list of hosts. Matches are ranked exact name, then prefix, then
+ * substring, so typing a host's full name always reaches it. Pinned hosts (current value, checked or
+ * muted ones) get their own `limit` so a long pinned set never crowds out other results, and both
+ * pools are cut by rank. Rows keep their input order (ties included), so clicking a row never moves it.
+ */
+export function boundHostList(
+ hosts: readonly T[],
+ options: { query?: string; pinnedIds?: ReadonlySet; limit?: number } = {}
+): BoundedHostList {
+ const { pinnedIds, limit = HOST_LIST_ROW_LIMIT } = options
+ const needle = (options.query ?? '').trim().toLowerCase()
+ const ranked: { host: T; rank: number; index: number }[] = []
+ hosts.forEach((host, index) => {
+ const rank = matchRank(host, needle)
+ if (rank !== null) {
+ ranked.push({ host, rank, index })
+ }
+ })
+ // Stable sort: equal ranks stay in input order.
+ ranked.sort((a, b) => a.rank - b.rank)
+ const pinned = ranked.filter((entry) => pinnedIds?.has(entry.host.id) === true)
+ const others = ranked.filter((entry) => pinnedIds?.has(entry.host.id) !== true)
+ const kept = [...pinned.slice(0, limit), ...others.slice(0, limit)]
+ kept.sort((a, b) => a.rank - b.rank || a.index - b.index)
+ return {
+ shown: kept.map((entry) => entry.host),
+ hiddenCount: Math.max(0, pinned.length - limit) + Math.max(0, others.length - limit)
+ }
+}
diff --git a/src/renderer/src/components/host-picker/host-list-menus.large-config.test.tsx b/src/renderer/src/components/host-picker/host-list-menus.large-config.test.tsx
new file mode 100644
index 00000000000..3ddd87236a2
--- /dev/null
+++ b/src/renderer/src/components/host-picker/host-list-menus.large-config.test.tsx
@@ -0,0 +1,102 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SidebarHostOption } from '../sidebar/sidebar-host-options'
+import { HOST_LIST_ROW_LIMIT } from './bounded-host-list'
+import { getDefaultSettings } from '../../../../shared/constants'
+
+// Why 13,692: the host count from the generated ~/.ssh/config in the renderer crash this guards.
+const HOST_COUNT = 13_692
+const NODE_BUDGET = 3_000
+const names = Array.from({ length: HOST_COUNT }, (_, i) => `node-${String(i).padStart(5, '0')}`)
+const lastName = names[HOST_COUNT - 1]!
+
+const hostOptions = names.map((name, i): SidebarHostOption => ({
+ id: `ssh:ssh-${i}`,
+ label: name,
+ detail: '',
+ kind: 'ssh',
+ health: 'disconnected',
+ presence: 'configured'
+}))
+
+// Why a pass-through menu: Radix only mounts sub-menu content after pointer or keyboard entry.
+vi.mock('@/components/ui/dropdown-menu', () => {
+ const Pass = ({ children }: { children?: React.ReactNode }) => {children}
+ return {
+ DropdownMenuSub: Pass,
+ DropdownMenuSubTrigger: Pass,
+ DropdownMenuSubContent: Pass,
+ DropdownMenuItem: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ DropdownMenuCheckboxItem: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ )
+ }
+})
+vi.mock('../sidebar/use-sidebar-host-scope-options', () => ({
+ useSidebarHostScopeOptions: () => ({ hostOptions, hostScopeOptions: [] })
+}))
+vi.mock('../settings/use-notification-source-options', () => ({
+ useNotificationSourceOptions: () => hostOptions
+}))
+
+const { SidebarHostScopeMenuSection } = await import('../sidebar/SidebarHostScopeMenuSection')
+const { NotificationHostToggles } = await import('../settings/NotificationHostToggles')
+const { WorkspaceDirectorySetting } = await import('../settings/WorkspaceDirectorySetting')
+
+function nodeCount(): number {
+ return document.body.getElementsByTagName('*').length
+}
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('host menus and settings with a 13,692-host ssh config', () => {
+ it('sidebar Hosts submenu keeps a filter’s checked hosts and bounds the rest', () => {
+ const lastId = hostOptions[HOST_COUNT - 1]!.id
+ render(
+
+ )
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ expect(screen.getAllByRole('menuitemcheckbox').length).toBeLessThanOrEqual(
+ HOST_LIST_ROW_LIMIT + 2
+ )
+ expect(screen.getByText(lastName)).toBeTruthy()
+ })
+
+ it('Notifications > Machines keeps muted hosts and finds the rest by search', () => {
+ const lastId = hostOptions[HOST_COUNT - 1]!.id
+ render(
+
+ )
+ fireEvent.click(screen.getByText('Machines'))
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ expect(screen.getByText(lastName)).toBeTruthy()
+ fireEvent.change(screen.getByRole('textbox', { name: 'Search hosts…' }), {
+ target: { value: 'node-00042' }
+ })
+ expect(screen.getByText('node-00042')).toBeTruthy()
+ })
+
+ it('Settings > General workspace directory "Apply to" mounts no host rows until opened', () => {
+ render(
+
+ )
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ expect(screen.queryByText(lastName)).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/host-picker/host-list-surfaces.large-config.test.tsx b/src/renderer/src/components/host-picker/host-list-surfaces.large-config.test.tsx
new file mode 100644
index 00000000000..0f7e1d68057
--- /dev/null
+++ b/src/renderer/src/components/host-picker/host-list-surfaces.large-config.test.tsx
@@ -0,0 +1,214 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SshTarget } from '../../../../shared/ssh-types'
+import type { SidebarHostOption } from '../sidebar/sidebar-host-options'
+import { HOST_LIST_ROW_LIMIT } from './bounded-host-list'
+import { HostCombobox } from './HostCombobox'
+import { Dialog, DialogContent } from '@/components/ui/dialog'
+import { SshTargetSelect } from '../settings/SshTargetSelect'
+import { SkillInstallMachineSelect } from '../skills/SkillInstallMachineSelect'
+import { RemoteStep } from '../sidebar/AddRepoRemoteStep'
+import { AddRepoHostSelector } from '../sidebar/AddRepoHostSelector'
+import { buildRunTargetRows } from '../new-workspace/run-target-options'
+import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
+
+// Why 13,692: the host count from the generated ~/.ssh/config in the renderer crash this guards.
+const HOST_COUNT = 13_692
+// Generous ceiling for one bounded surface; drawing every host costs well over 100k nodes.
+const NODE_BUDGET = 3_000
+
+const names = Array.from({ length: HOST_COUNT }, (_, i) => `node-${String(i).padStart(5, '0')}`)
+const lastName = names[HOST_COUNT - 1]!
+
+function nodeCount(): number {
+ return document.body.getElementsByTagName('*').length
+}
+
+function sshTarget(name: string, index: number): SshTarget {
+ return { id: `ssh-${index}`, label: name, host: name, port: 22, username: 'deploy' }
+}
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('host lists with a 13,692-host ssh config', () => {
+ it('HostCombobox mounts nothing while closed and a bounded, searchable list when open', async () => {
+ render(
+ ({ id: `ssh-${i}`, label: name }))}
+ />
+ )
+ expect(nodeCount()).toBeLessThan(50)
+ fireEvent.click(screen.getByRole('combobox', { name: 'Host' }))
+ const search = await screen.findByRole('combobox', { name: 'Search hosts…' })
+ // The current choice is pinned on top of a full page of others.
+ expect(screen.getAllByRole('option')).toHaveLength(HOST_LIST_ROW_LIMIT + 1)
+ expect(screen.getByText(/more hosts\. Search by name/)).toBeTruthy()
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ fireEvent.change(search, { target: { value: lastName } })
+ expect(screen.getAllByRole('option')[0]?.textContent).toContain(lastName)
+ })
+
+ it('HostCombobox treats re-picking the current host as no change', async () => {
+ const onChange = vi.fn()
+ render(
+ ({ id: `ssh-${i}`, label: name }))}
+ />
+ )
+ fireEvent.click(screen.getByRole('combobox', { name: 'Host' }))
+ fireEvent.click(await screen.findByRole('option', { name: names[0] }))
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('Settings > Servers SSH host picker', async () => {
+ const onChange = vi.fn()
+ render(
+ <>
+
+
+ >
+ )
+ expect(nodeCount()).toBeLessThan(50)
+ fireEvent.click(screen.getByRole('combobox', { name: 'SSH host' }))
+ const search = await screen.findByRole('combobox', { name: 'Search hosts…' })
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ fireEvent.change(search, { target: { value: lastName } })
+ fireEvent.click(screen.getByRole('option', { name: lastName }))
+ expect(onChange).toHaveBeenCalledWith(`ssh-${HOST_COUNT - 1}`)
+ })
+
+ it('skill install machine picker', async () => {
+ render(
+ ({ id: `ssh-${i}`, label: name, connected: false }))}
+ />
+ )
+ expect(nodeCount()).toBeLessThan(50)
+ fireEvent.click(screen.getByRole('combobox'))
+ await screen.findByRole('combobox', { name: 'Search hosts…' })
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ })
+
+ it('Add Project remote step lists a server’s SSH hosts bounded, with the selection kept', () => {
+ render(
+
+ )
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ expect(screen.getAllByText(lastName).length).toBeGreaterThan(0)
+ fireEvent.change(screen.getByRole('textbox', { name: 'Search hosts…' }), {
+ target: { value: 'node-00042' }
+ })
+ expect(screen.getByText('node-00042')).toBeTruthy()
+ })
+
+ it('Add Project host selector', () => {
+ const hosts: SidebarHostOption[] = names.map((name, i) => ({
+ id: `ssh:ssh-${i}`,
+ label: name,
+ detail: '',
+ kind: 'ssh',
+ health: 'disconnected',
+ presence: 'configured'
+ }))
+ render(
+
+ )
+ expect(nodeCount()).toBeLessThan(NODE_BUDGET)
+ const list = screen.getByRole('listbox')
+ expect(within(list).getAllByRole('option').length).toBeLessThanOrEqual(HOST_LIST_ROW_LIMIT + 1)
+ })
+
+ it('Add Project host search hides setup actions so Enter picks the typed host', () => {
+ const hosts: SidebarHostOption[] = names.map((name, i) => ({
+ id: `runtime:server-${i}`,
+ label: name,
+ detail: '',
+ kind: 'runtime',
+ health: 'available',
+ presence: 'configured'
+ }))
+ const onSelectHost = vi.fn()
+ render(
+
+ )
+ expect(screen.getByText('Add remote host')).toBeTruthy()
+ const search = screen.getByRole('combobox', { name: 'Search hosts…' })
+ fireEvent.change(search, { target: { value: lastName } })
+ expect(screen.queryByText('Add remote host')).toBeNull()
+ fireEvent.keyDown(search, { key: 'Enter' })
+ expect(onSelectHost).toHaveBeenCalledWith(`runtime:server-${HOST_COUNT - 1}`)
+ })
+
+ it('composer run-target rows bound hosts that still need setup', () => {
+ const hostOptions = names.map((name, i): ProjectHostSetupOption => ({
+ kind: 'needs-setup',
+ id: `setup-${i}`,
+ projectId: 'project-1',
+ hostId: `ssh:ssh-${i}`,
+ label: name,
+ detail: 'Connect to set up',
+ isAvailable: false,
+ attention: false,
+ canSetLocation: false
+ }))
+ const all = buildRunTargetRows({ hostOptions, recipes: [], query: '', hasAddHost: false })
+ expect(all.rows.length).toBe(HOST_LIST_ROW_LIMIT)
+ expect(all.hiddenHostCount).toBe(HOST_COUNT - HOST_LIST_ROW_LIMIT)
+ const exact = buildRunTargetRows({
+ hostOptions,
+ recipes: [],
+ query: lastName,
+ hasAddHost: false
+ })
+ expect(exact.rows[0]?.kind === 'needs-setup' && exact.rows[0].option.label).toBe(lastName)
+ })
+})
diff --git a/src/renderer/src/components/host-picker/host-picker-copy.ts b/src/renderer/src/components/host-picker/host-picker-copy.ts
new file mode 100644
index 00000000000..dd0ca93d311
--- /dev/null
+++ b/src/renderer/src/components/host-picker/host-picker-copy.ts
@@ -0,0 +1,23 @@
+import { translate } from '@/i18n/i18n'
+
+export function searchHostsLabel(): string {
+ return translate('auto.components.hostPicker.searchHosts', 'Search hosts…')
+}
+
+export function noHostsMatchLabel(): string {
+ return translate('auto.components.hostPicker.noHostsMatch', 'No hosts match.')
+}
+
+export function moreHostsLabel(count: number): string {
+ return count === 1
+ ? translate(
+ 'auto.components.hostPicker.moreHosts_one',
+ '{{count}} more host. Search by name to find it.',
+ { count }
+ )
+ : translate(
+ 'auto.components.hostPicker.moreHosts_other',
+ '{{count}} more hosts. Search by name to find one.',
+ { count }
+ )
+}
diff --git a/src/renderer/src/components/host-picker/use-bounded-host-rows.ts b/src/renderer/src/components/host-picker/use-bounded-host-rows.ts
new file mode 100644
index 00000000000..94a5dbca03e
--- /dev/null
+++ b/src/renderer/src/components/host-picker/use-bounded-host-rows.ts
@@ -0,0 +1,19 @@
+import { useState } from 'react'
+import { boundHostList, HOST_LIST_ROW_LIMIT, type BoundedHostList } from './bounded-host-list'
+
+/**
+ * A host checklist's rows: pinned hosts plus up to the limit of the rest, narrowed by a search the
+ * list shows only once there are more hosts than fit.
+ */
+export function useBoundedHostRows(
+ hosts: readonly T[],
+ pinnedIds?: ReadonlySet
+): BoundedHostList & {
+ query: string
+ setQuery: (query: string) => void
+ searchable: boolean
+} {
+ const [query, setQuery] = useState('')
+ const searchable = hosts.length > HOST_LIST_ROW_LIMIT || query !== ''
+ return { ...boundHostList(hosts, { query, pinnedIds }), query, setQuery, searchable }
+}
diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx
index fcab5b2c5a5..8d8306abfe4 100644
--- a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx
+++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx
@@ -21,6 +21,7 @@ import {
import { AddHostSubmenuRow } from './RunTargetSubmenus'
import RunTargetRowList from './RunTargetRowList'
import RunTargetField from './RunTargetField'
+import { HostListOverflowNote } from '@/components/host-picker/HostListOverflowNote'
type RunTargetComboboxProps = {
hostOptions: readonly ProjectHostSetupOption[]
@@ -74,7 +75,7 @@ export default function RunTargetCombobox({
const { query, setQuery, open, setOpen, armedKey, arm, moveArm, inputRef, listId, setListNode } =
combobox
- const { rows, matchedRecipes } = useMemo(
+ const { rows, matchedRecipes, hiddenHostCount } = useMemo(
() => buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }),
[hasAddHost, hostOptions, query, recipes]
)
@@ -308,6 +309,8 @@ export default function RunTargetCombobox({
onSetLocation={setLocation}
/>
+ {/* Why outside the listbox: the note is not an option. */}
+
{hasAddHost ? (
[number]
@@ -54,11 +55,15 @@ function matches(haystack: string, query: string): boolean {
return haystack.toLowerCase().includes(query)
}
-/** Ready hosts first, then hosts that still need a connection or a project location. */
+/**
+ * Ready hosts first, then hosts that still need a connection or a project location. Ready hosts
+ * already hold the project, so they are few; the rest can be every configured SSH host, so they
+ * are bounded and ranked by name.
+ */
function buildHostRows(
hostOptions: readonly ProjectHostSetupOption[],
query: string
-): RunTargetRowModel[] {
+): { rows: RunTargetRowModel[]; hiddenHostCount: number } {
const hostMatches = (option: ProjectHostSetupOption): boolean =>
query === '' ||
matches(option.label, query) ||
@@ -69,22 +74,34 @@ function buildHostRows(
(option): option is ReadyProjectHostSetupOption =>
option.kind === 'ready' && hostMatches(option)
)
- const needsSetup = hostOptions.filter(
- (option): option is NeedsSetupProjectHostOption =>
- option.kind === 'needs-setup' && hostMatches(option)
+ const needsSetup = boundHostList(
+ hostOptions
+ .filter(
+ (option): option is NeedsSetupProjectHostOption =>
+ option.kind === 'needs-setup' && hostMatches(option)
+ )
+ .map((option) => ({
+ id: getProjectHostSetupSelectionKey(option),
+ label: option.label,
+ searchText: option.detail,
+ option
+ })),
+ // Why the query again: ranks an exact name first so typing it always reaches the host.
+ { query }
)
- return [
+ const rows: RunTargetRowModel[] = [
...ready.map((option) => ({
key: `host:${getProjectHostSetupSelectionKey(option)}`,
kind: 'ready' as const,
option
})),
- ...needsSetup.map((option) => ({
- key: `needs:${getProjectHostSetupSelectionKey(option)}`,
+ ...needsSetup.shown.map(({ id, option }) => ({
+ key: `needs:${id}`,
kind: 'needs-setup' as const,
option
}))
]
+ return { rows, hiddenHostCount: needsSetup.hiddenCount }
}
function matchRecipes(
@@ -127,12 +144,18 @@ export function buildRunTargetRows({
recipes: readonly EphemeralVmRecipeOption[]
query: string
hasAddHost: boolean
-}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } {
+}): {
+ rows: RunTargetRowModel[]
+ matchedRecipes: EphemeralVmRecipeOption[]
+ /** Hosts the row limit left out; typing narrows them in. */
+ hiddenHostCount: number
+} {
const trimmed = query.trim().toLowerCase()
const matchedRecipes = matchRecipes(recipes, trimmed)
+ const hostRows = buildHostRows(hostOptions, trimmed)
const rows: RunTargetRowModel[] = [
- ...buildHostRows(hostOptions, trimmed),
+ ...hostRows.rows,
...buildSubmenuRows(matchedRecipes.length > 0, hasAddHost)
]
- return { rows, matchedRecipes }
+ return { rows, matchedRecipes, hiddenHostCount: hostRows.hiddenHostCount }
}
diff --git a/src/renderer/src/components/settings/NotificationHostToggles.tsx b/src/renderer/src/components/settings/NotificationHostToggles.tsx
index f63129375f7..92c3cc58084 100644
--- a/src/renderer/src/components/settings/NotificationHostToggles.tsx
+++ b/src/renderer/src/components/settings/NotificationHostToggles.tsx
@@ -6,6 +6,9 @@ import { NotificationSettingToggle } from './NotificationSettingToggle'
import { useNotificationSourceOptions } from './use-notification-source-options'
import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host'
import { translate } from '@/i18n/i18n'
+import { useBoundedHostRows } from '../host-picker/use-bounded-host-rows'
+import { HostListSearchField } from '../host-picker/HostListSearchField'
+import { HostListOverflowNote } from '../host-picker/HostListOverflowNote'
type NotificationHostTogglesProps = {
mutedNotificationSourceIds: readonly NotificationSourceId[]
@@ -23,7 +26,10 @@ export function NotificationHostToggles({
const mutedSourceIds = new Set(mutedNotificationSourceIds)
const isMuted = (host: (typeof hostOptions)[number]): boolean =>
[host.id, ...(host.aliasHostIds ?? [])].some((id) => mutedSourceIds.has(id))
- const mutedCount = hostOptions.filter(isMuted).length
+ const mutedHosts = hostOptions.filter(isMuted)
+ const mutedCount = mutedHosts.length
+ // Why muted ones pinned: an off switch must never be hidden behind the row cap.
+ const rows = useBoundedHostRows(hostOptions, new Set(mutedHosts.map((host) => host.id)))
// Keep an effective mute reachable after the last remote machine is removed.
if (hostOptions.length <= 1 && mutedCount === 0) {
return null
@@ -62,7 +68,10 @@ export function NotificationHostToggles({
- {hostOptions.map((host) => (
+ {rows.searchable ? (
+
+ ) : null}
+ {rows.shown.map((host) => (
onChange([host.id, ...(host.aliasHostIds ?? [])], !isMuted(host))}
/>
))}
+
diff --git a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx
index a3527747681..f035ad60bb3 100644
--- a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx
+++ b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx
@@ -9,7 +9,7 @@ import type {
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { HostCombobox } from '../host-picker/HostCombobox'
import type { SetupHostOption } from './repository-host-setup-options'
import {
HostSetupCloneStep,
@@ -217,28 +217,22 @@ export function RepositoryHostSetupActions({
-
+ {
+ const option = setupHostOptions.find((candidate) => candidate.id === value)
+ if (option) {
+ setSelectedSetupHostId(option.id)
+ }
+ }}
+ options={setupHostOptions.map((option) => ({
+ id: option.id,
+ label: option.label,
+ disabled: !option.isAvailable,
+ detail: !option.isAvailable || !option.canUsePathActions ? option.detail : undefined
+ }))}
+ className="min-w-0"
+ />
{(!canUseSetupTargetHost || !canUsePathActions) && setupTargetHostOption ? (
{setupTargetHostOption.detail}
) : null}
diff --git a/src/renderer/src/components/settings/SshTargetSelect.tsx b/src/renderer/src/components/settings/SshTargetSelect.tsx
index 91294c7b123..b4a96e8960b 100644
--- a/src/renderer/src/components/settings/SshTargetSelect.tsx
+++ b/src/renderer/src/components/settings/SshTargetSelect.tsx
@@ -1,5 +1,5 @@
import type { SshTarget } from '../../../../shared/ssh-types'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { HostCombobox } from '../host-picker/HostCombobox'
type SshTargetSelectProps = {
id?: string
@@ -17,17 +17,12 @@ export function SshTargetSelect({
placeholder
}: SshTargetSelectProps): React.JSX.Element {
return (
-
+
)
}
diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx
index 6ca5e985170..fc4056df4f9 100644
--- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx
+++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx
@@ -11,7 +11,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { HostCombobox } from '../host-picker/HostCombobox'
import { SearchableSetting } from './SearchableSetting'
import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
@@ -171,21 +171,18 @@ export function WorkspaceDirectorySetting({
'Apply to'
)}
-
+ onChange={(next) => {
+ const choice = choices.find((candidate) => candidate.scope === next)
+ if (choice) {
+ setScope(choice.scope)
+ }
+ }}
+ options={choices.map((choice) => ({ id: choice.scope, label: choice.label }))}
+ size="xs"
+ className="w-44"
+ />
)}
diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx
index d766ac4ab7b..4b237ce425b 100644
--- a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx
@@ -1,7 +1,10 @@
import { useState } from 'react'
import { Check, ChevronRight, ChevronsUpDown, Loader2, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
-import { Command, CommandItem, CommandList } from '@/components/ui/command'
+import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
+import { useBoundedHostRows } from '../host-picker/use-bounded-host-rows'
+import { HostListOverflowNote } from '../host-picker/HostListOverflowNote'
+import { searchHostsLabel } from '../host-picker/host-picker-copy'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import type { SidebarHostOption } from './sidebar-host-options'
@@ -50,6 +53,7 @@ export function AddRepoHostSelector({
onOpenWslHostsSetting
}: AddRepoHostSelectorProps): React.JSX.Element | null {
const [addHostOpen, setAddHostOpen] = useState(false)
+ const rows = useBoundedHostRows(hosts, new Set(selectedHostId ? [selectedHostId] : []))
const showHostSetupActions = Boolean(onAddSshHost || onAddRemoteServer || onAddWslHost)
if (!shouldShowHostScopeControls(hosts) && !showHostSetupActions) {
return null
@@ -89,9 +93,18 @@ export function AddRepoHostSelector({
align="start"
className="w-[min(340px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
>
-
+
+ {rows.searchable ? (
+
+ ) : null}
- {showHostSetupActions ? (
+ {/* Why hidden while searching: Enter must pick the matching host, not open setup. */}
+ {showHostSetupActions && rows.query === '' ? (
) : null}
- {hosts.map((host) => {
+ {rows.shown.map((host) => {
const selected = host.id === selectedHostId
const disabled = !canSelectAddRepoHost(host)
const canConnectHost = canConnectAddRepoHost(host)
@@ -288,6 +301,7 @@ export function AddRepoHostSelector({
)
})}
+
diff --git a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx
index 2304e77bfae..c086bdb0403 100644
--- a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx
@@ -8,6 +8,9 @@ import { RemoteFileBrowser } from './RemoteFileBrowser'
import { SshTargetRow } from './SshTargetRow'
import { AddServerSshHostDialog } from './AddServerSshHostDialog'
import { translate } from '@/i18n/i18n'
+import { useBoundedHostRows } from '../host-picker/use-bounded-host-rows'
+import { HostListSearchField } from '../host-picker/HostListSearchField'
+import { HostListOverflowNote } from '../host-picker/HostListOverflowNote'
type RemoteStepProps = {
sshTargets: (SshTarget & { state?: SshConnectionState })[]
@@ -41,6 +44,10 @@ export function RemoteStep({
}: RemoteStepProps): React.JSX.Element {
const [browsing, setBrowsing] = useState(false)
const [addingServerHost, setAddingServerHost] = useState(false)
+ const targetRows = useBoundedHostRows(
+ sshTargets,
+ new Set(selectedTargetId ? [selectedTargetId] : [])
+ )
const selectedTarget = selectedTargetId
? sshTargets.find((target) => target.id === selectedTargetId)
: null
@@ -111,7 +118,10 @@ export function RemoteStep({
) : (
- {sshTargets.map((target) => (
+ {targetRows.searchable ? (
+
+ ) : null}
+ {targetRows.shown.map((target) => (
))}
+
)}