fix(mobile): stabilize pairing option layout

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 20:21:43 -04:00
co-authored by Orca
parent b1b491cc36
commit aea425fe17
4 changed files with 225 additions and 94 deletions
@@ -206,41 +206,36 @@ export function HeroFlow({
compact
/>
{connectionMode === 'local-only' ? (
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
// Why: when discovery is empty, a custom address is the
// only route to a Tailscale hostname or static IP.
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn(
'mp-network-refresh',
refreshingNetworkInterfaces && 'is-spinning'
)}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
) : null}
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
// Why: direct-first and local-only pairing both advertise a
// local route; keeping it visible also prevents mode shifts.
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
<div className="mp-inline-actions">
<span className="mp-action-divider">
@@ -36,6 +36,135 @@ function relayStatusLabel(status: MobileRelayStatus): string {
)
}
type CompactConnectionOptionsProps = {
value: MobilePairingConnectionMode
onChange: (value: MobilePairingConnectionMode) => void
signedIn: boolean
configured: boolean
connecting: boolean
connect: () => Promise<unknown>
relayStatus: MobileRelayStatus
}
function CompactConnectionOptions({
value,
onChange,
signedIn,
configured,
connecting,
connect,
relayStatus
}: CompactConnectionOptionsProps): React.JSX.Element {
return (
<section className="w-full space-y-2">
<h3 className="text-sm font-medium">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.title',
'How should the new phone connect?'
)}
</h3>
<div className="grid grid-cols-2 gap-2" role="radiogroup">
<label
className={cn(
'flex min-h-16 items-center gap-2 rounded-lg border border-border/60 px-3 py-2',
signedIn ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',
value === 'automatic' && signedIn && 'bg-accent'
)}
>
<input
type="radio"
name="mobile-pairing-connection-mode"
value="automatic"
checked={value === 'automatic'}
disabled={!signedIn}
onChange={() => onChange('automatic')}
className="size-4 shrink-0 accent-primary"
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.anywhere',
'Connect from anywhere'
)}
</span>
<span className="block text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.recommended',
'Recommended'
)}
</span>
</span>
{signedIn ? (
<Badge variant="outline" className="shrink-0">
{relayStatusLabel(relayStatus)}
</Badge>
) : !configured ? (
<Badge variant="outline" className="shrink-0">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.unavailable',
'Unavailable'
)}
</Badge>
) : null}
</label>
<label
className={cn(
'flex min-h-16 cursor-pointer items-center gap-2 rounded-lg border border-border/60 px-3 py-2',
value === 'local-only' && 'bg-accent'
)}
>
<input
type="radio"
name="mobile-pairing-connection-mode"
value="local-only"
checked={value === 'local-only'}
onChange={() => onChange('local-only')}
className="size-4 shrink-0 accent-primary"
/>
<span className="text-sm font-medium">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.localOnly',
'Local network only'
)}
</span>
</label>
</div>
<div className="flex min-h-9 items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{!signedIn
? translate(
'auto.components.settings.MobilePairingConnectionOptions.signInDescription',
'Sign in on this desktop to use Orca Relay.'
)
: value === 'automatic'
? translate(
'auto.components.settings.MobilePairingConnectionOptions.automaticDescription',
'Orca uses a direct connection when available and Relay otherwise.'
)
: translate(
'auto.components.settings.MobilePairingConnectionOptions.localDescription',
'Uses LAN or Tailscale without connecting this phone through Orca Relay.'
)}
</p>
{!signedIn && configured ? (
<Button
type="button"
size="xs"
className="shrink-0"
disabled={connecting}
onClick={() => void connect()}
>
{connecting ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.settings.MobilePairingConnectionOptions.signIn', 'Sign in')}
</Button>
) : null}
</div>
</section>
)
}
export function MobilePairingConnectionOptions({
value,
onChange,
@@ -75,8 +204,22 @@ export function MobilePairingConnectionOptions({
}
}, [])
if (compact) {
return (
<CompactConnectionOptions
value={value}
onChange={onChange}
signedIn={signedIn}
configured={configured}
connecting={connecting}
connect={connect}
relayStatus={relayStatus}
/>
)
}
return (
<section className={cn('space-y-2', compact && 'max-w-md')}>
<section className="space-y-2">
<h3 className="text-sm font-medium">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.title',
@@ -87,7 +230,7 @@ export function MobilePairingConnectionOptions({
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-border/60 px-3',
compact ? 'py-2' : 'py-3',
'py-3',
value === 'automatic' && signedIn && 'bg-accent'
)}
>
@@ -160,7 +303,7 @@ export function MobilePairingConnectionOptions({
<label
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border border-border/60 px-3',
compact ? 'py-2' : 'py-3',
'py-3',
value === 'local-only' && 'bg-accent'
)}
>
@@ -43,20 +43,18 @@ function renderSection(
}
describe('MobilePairingSetupSection', () => {
it('opens local settings by default for local-only pairing', () => {
it('keeps local settings visible for local-only pairing', () => {
renderSection()
expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)')
expect(screen.getByText(/connects only through the local network address/i)).toBeVisible()
})
it('keeps local settings advanced for automatic Relay fallback', async () => {
const { user } = renderSection({ connectionMode: 'automatic' })
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
it('keeps local settings visible for automatic direct-first pairing', () => {
renderSection({ connectionMode: 'automatic' })
expect(screen.getByRole('combobox')).toBeVisible()
expect(
screen.getByText(/includes direct access and encrypted Orca Relay fallback/i)
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Local connection settings' }))
expect(screen.getByRole('combobox')).toBeVisible()
})
it('commits an OS interface picked from the list', async () => {
@@ -69,62 +69,57 @@ export function MobilePairingSetupSection({
)}
</Button>
<Accordion
key={connectionMode}
type="single"
collapsible
defaultValue={connectionMode === 'local-only' ? 'local-address' : undefined}
className="mt-4 border-t border-border/60 pt-2"
>
<AccordionItem value="local-address">
<AccordionTrigger className="py-2 text-xs">
<div className="mt-4 space-y-3 border-t border-border/60 pt-4">
<div className="space-y-1">
<h4 className="text-xs font-medium">
{translate(
'auto.components.settings.MobilePairingSetupSection.localSettings',
'Local connection settings'
)}
</AccordionTrigger>
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
<p>
</h4>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.MobilePairingSetupSection.localAddressDescription',
'Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.'
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
className="min-w-[220px] justify-between font-normal"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.settings.MobilePairingSetupSection.refresh',
'Refresh network interfaces'
)}
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.settings.MobilePairingSetupSection.localAddressDescription',
'Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.'
'auto.components.settings.MobilePairingSetupSection.refresh',
'Refresh network interfaces'
)}
</p>
<div className="flex flex-wrap items-center gap-3">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
className="min-w-[220px] justify-between font-normal"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.settings.MobilePairingSetupSection.refresh',
'Refresh network interfaces'
)}
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.settings.MobilePairingSetupSection.refresh',
'Refresh network interfaces'
)}
</TooltipContent>
</Tooltip>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="tailnet-guide">
</TooltipContent>
</Tooltip>
</div>
</div>
<Accordion type="single" collapsible className="mt-2">
<AccordionItem value="tailnet-guide" className="border-b-0">
<AccordionTrigger className="py-2 text-xs">
{translate(
'auto.components.settings.MobilePairingSetupSection.tailnet',