mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
8e7a567f69
* feat: pilot agent outbound-mode for remote nodes Adds a second mode for managing remote nodes: the agent dials an outbound WebSocket tunnel to the primary, so the remote host no longer needs an inbound port, a reachable URL, or its own TLS certificate. Works behind NAT, residential routers, and corporate firewalls. The primary multiplexes HTTP and WebSocket requests over a single tunnel via a hybrid JSON + binary frame protocol, bridged through a per-tunnel loopback server so existing proxy and upgrade handlers route pilot-mode nodes identically to proxy-mode ones. Enrollment uses a single-use 15-minute pilot_enroll JWT exchanged for a long-lived pilot_tunnel credential on first connect. Proxy mode continues to work unchanged and both modes are supported side-by-side. * test(e2e): switch to proxy mode before asserting api_url field Remote nodes default to Pilot Agent mode, which hides the api_url input. The SSRF-validation tests need proxy mode, so the helper now selects Distributed API Proxy after picking Remote type before asserting the field is visible. * fix(e2e): wire Combobox id prop so node-mode selector resolves The Combobox trigger button had no id, leaving its Label orphaned and making getByRole name-based lookups fail. Adding id to the primitive, passing id="node-mode" from NodeManager, and updating the E2E helper to use #node-mode fixes both the a11y regression and the CI timeout.
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
import * as React from "react"
|
|
import { Check, ChevronsUpDown } from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export interface ComboboxOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
interface ComboboxProps {
|
|
options: ComboboxOption[]
|
|
value: string
|
|
onValueChange: (value: string) => void
|
|
placeholder?: string
|
|
searchPlaceholder?: string
|
|
emptyText?: string
|
|
disabled?: boolean
|
|
className?: string
|
|
id?: string
|
|
}
|
|
|
|
export function Combobox({
|
|
options,
|
|
value,
|
|
onValueChange,
|
|
placeholder = "Select...",
|
|
searchPlaceholder,
|
|
emptyText = "No results found.",
|
|
disabled = false,
|
|
className,
|
|
id,
|
|
}: ComboboxProps) {
|
|
const [open, setOpen] = React.useState(false)
|
|
const [search, setSearch] = React.useState("")
|
|
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
|
|
const selectedLabel = options.find((o) => o.value === value)?.label
|
|
|
|
const filtered = search
|
|
? options.filter((o) =>
|
|
o.label.toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
: options
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return
|
|
const handler = (e: MouseEvent) => {
|
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
setSearch("")
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handler)
|
|
return () => document.removeEventListener("mousedown", handler)
|
|
}, [open])
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation()
|
|
setOpen(false)
|
|
setSearch("")
|
|
}
|
|
}
|
|
document.addEventListener("keydown", handler, true)
|
|
return () => document.removeEventListener("keydown", handler, true)
|
|
}, [open])
|
|
|
|
const handleSelect = (option: ComboboxOption) => {
|
|
onValueChange(option.value === value ? "" : option.value)
|
|
setOpen(false)
|
|
setSearch("")
|
|
}
|
|
|
|
return (
|
|
<div ref={wrapperRef} className={cn("relative w-full", className)}>
|
|
{/* Trigger: static button when closed, inline search input when open */}
|
|
{open ? (
|
|
<div
|
|
className="flex h-9 w-full items-center rounded-md border border-ring bg-transparent px-3 text-sm shadow-sm ring-1 ring-ring"
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={searchPlaceholder ?? selectedLabel ?? placeholder}
|
|
className="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
autoFocus
|
|
/>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
role="combobox"
|
|
id={id}
|
|
aria-expanded={false}
|
|
disabled={disabled}
|
|
onClick={() => { if (!disabled) setOpen(true) }}
|
|
className={cn(
|
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-glass-border bg-input px-3 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
!value && "text-muted-foreground"
|
|
)}
|
|
>
|
|
<span className="line-clamp-1">
|
|
{selectedLabel ?? placeholder}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Options list — absolutely positioned overlay */}
|
|
{open && (
|
|
<div className="absolute left-0 top-full -mt-px z-50 w-full rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
|
<div className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
|
{filtered.length === 0 ? (
|
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
{emptyText}
|
|
</div>
|
|
) : (
|
|
filtered.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => handleSelect(option)}
|
|
className={cn(
|
|
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground",
|
|
value === option.value && "bg-accent/50"
|
|
)}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4 shrink-0",
|
|
value === option.value ? "opacity-100" : "opacity-0"
|
|
)}
|
|
strokeWidth={1.5}
|
|
/>
|
|
{option.label}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|