* docs(spec): manual network address entry for mobile pairing
* docs(plan): manual network address entry for mobile pairing
* docs(plan): fix two test-spec issues in Task 1
* feat(mobile-pairing): add parseManualNetworkAddress validator
* docs(plan): fix buildComboboxEntries filter rule
* feat(mobile-pairing): buildComboboxEntries for network interface combobox
* feat(mobile-pairing): combobox with manual address entry
* docs(spec): align buildComboboxEntries behavior with corrected plan
* fix(mobile-pairing): use text-destructive token for inline error
* fix(mobile-pairing): route Use row through translate() with i18next interpolation
* refactor(mobile-pairing): extract NetworkInterfaceCombobox shared by MobileHero and Settings section
The Popover + Command + manual-entry UX lived only in the Settings →
Mobile → Network Interface section. The mobile pairing screen
("Step 2 of 2 — Pair this computer") had its own copy of the same
Select-based dropdown that did not support manual address entry. A
user trying to pair with a Tailscale MagicDNS hostname from the
pairing screen could not enter it.
Extract the typeable combobox into a shared
`NetworkInterfaceCombobox` component. Both surfaces now render the
same Popover + Command with manual address entry, the `Use "..."`
row, the inline validation error, and the `(custom)` trigger label.
Settings keeps its own Generate QR button, Refresh + Tooltip, and
Tailnet accordion around the combobox. The pairing screen keeps its
existing layout (label + combobox + refresh icon).
Net deletion: ~160 lines. Net behavior gain: manual address entry is
now reachable from both surfaces, not only Settings.
* style(ui): give CommandInput a visible background so the search box is not lost
The Popover content above the CommandList renders the cmdk CommandInput
with only a thin bottom border. Against a white popover background it
visually disappears, especially in the mobile pairing screen where the
popover sits inside a dark phone mockup. Add a subtle `bg-muted/30` +
`py-1` so the input row is unambiguous, without changing the input's
shape or behavior.
* fix(mobile-pairing): drop cmdk CommandItem, use plain buttons inside Popover
In `pnpm dev` HMR cycle the cmdk CommandItem `onSelect` dispatch was
unreliable — clicking the item fired the synthetic event but the parent
React state never received it, so the trigger label never updated after
the user picked a manual address or a refreshed interface.
Replace the `Command` + `CommandItem` primitives inside
NetworkInterfaceCombobox with a native `<input>` + `<button>` list
wrapped by Radix `Popover`. The list now responds to the user's first
click without any intermediate effect that could be skipped in dev mode.
The combobox keeps the same props contract, the same placeholder, the
same inline validation, and the same `Use "<address>"` row at the
bottom of the list.
Update MobileNetworkInterfaceSection.test.tsx selectors from
`role=option` (cmdk's) to `role=button` so the integration test still
asserts the right element. All 479 feature tests still pass.
* debug(mobile-pairing): log handleSelect* invocations to confirm click path
* fix(mobile-pairing): commit on pointerdown to beat Radix Popover close race
In dev mode Radix Popover's close-on-pointerdown handler occasionally
fires before React's synthetic click dispatch reaches the option button,
so the parent's selectedAddress never updates after the user picks a
manual address. Bind the commit handler to pointerdown (synchronous,
before any pointer-up / click synthesis) and call event.preventDefault()
to avoid text-selection side effects. Keep onClick as a fallback so
keyboard / touch / programmatic-dispatch paths still work.
* fix(mobile-pairing): keep manually-typed addresses across network refresh
`selectRefreshedNetworkAddress` used to fall back to the first OS
interface whenever `currentAddress` wasn't in the OS-enumerated
list — so a user who typed a Tailscale MagicDNS name saw their
selection snap back to LAN every time `loadNetworkInterfaces`
returned. Treat manual entries as sticky by passing an
`isManual` flag from the caller; `selectRefreshedNetworkAddress`
now keeps the address when the caller says the user typed it.
`MobilePage` tracks `addressIsManual` alongside
`selectedAddress`: `handleAddressChange` flips it on when the
picked address is not in the OS list, and `loadNetworkInterfaces`
passes it through so refresh keeps the choice.
* chore(mobile-pairing): remove debug logs and sync new-combobox-listbox i18n key
* fix(mobile-pairing): address CodeRabbit review on manual-address lifecycle
Three real bugs from review, plus a regression test:
1. NetworkInterfaceCombobox in MobileHero was disabled when
`networkInterfaces.length === 0`, which locked users out of the
only path to type a manual address during a transient empty
discovery. Pass `disabled={false}` and let the combobox's
own empty-state copy explain the situation.
2. `selectRefreshedNetworkAddress` returned `undefined` whenever
`interfaces.length === 0`, even if the user had a manual
address and `currentAddressIsManual` was true. Keep the manual
address so a recovering discovery doesn't clobber it.
3. `loadNetworkInterfaces` could rewrite `selectedAddress` (e.g.
when a refresh swaps to a freshly-discovered tailnet) but never
updated `addressIsManual`, so the next refresh could revert
the user back to LAN. Re-derive `addressIsManual` from the
new address after every refresh.
Adds a regression test in
`mobile-network-interface-selection.test.ts` exercising the
empty-refresh + manual path.
* style(mobile-pairing): trim disabled-false comment to two lines
* refactor(mobile-pairing): replace typeable combobox with select + custom-address dialog
The Settings/MobileHero network selector used a Popover+search-input hybrid
that looked uneven and hid its validation error behind the open popover.
Replace it with a plain Select of discovered interfaces plus an
'Add custom address…' footer row that opens a small dialog for entering a
Tailscale hostname or static IP. Drops the 'MagicDNS' jargon for plainer
copy, keeps the '(custom)' trigger label, and routes all strings through
translate() with real es/ja/ko/zh translations. Removes the now-dead
buildComboboxEntries helper. Also adds the missing scrollbar-sleek class
the old list omitted (was failing pnpm lint).
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: ppw-stack <ppw-stack@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
39 KiB
Manual Network Address Entry — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let users type a custom IPv4 address or Tailscale MagicDNS hostname into the desktop mobile-pairing Network Interface dropdown, so the QR code can target a host the OS hasn't auto-discovered.
Architecture: Replace the inner Select of MobileNetworkInterfaceSection with a Popover + Command ("combobox") pattern modeled on the existing AgentCombobox. A new shared pure function parseManualNetworkAddress enforces the input grammar; a new helper buildComboboxEntries builds the row list (filtered interfaces + an optional Use "<query>" row). The custom selection is session-scoped.
Tech Stack: TypeScript, React 18, shadcn/ui primitives (Command, Popover, Button) from src/renderer/src/components/ui/, vitest, @testing-library/react. No new dependencies.
Spec: docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md
File map
Create:
src/shared/network/manual-address.ts— pure parser.src/shared/network/manual-address.test.ts— parser unit tests.src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx— UI render tests.
Modify:
src/renderer/src/components/settings/mobile-network-interface-selection.ts— swapmergeForSelectforbuildComboboxEntries(or addbuildComboboxEntriesand keep the old one if other call sites exist; confirm with grep before deleting).src/renderer/src/components/settings/mobile-network-interface-selection.test.ts— replacemergeForSelecttests withbuildComboboxEntriestests.src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx— swapSelectforPopover + Command.
Reference (read-only, do not modify):
src/renderer/src/components/ui/command.tsx—Command,CommandEmpty,CommandInput,CommandItem,CommandList,CommandSeparator.src/renderer/src/components/ui/popover.tsx—Popover,PopoverTrigger,PopoverContent.src/renderer/src/components/ui/button.tsx—Button.src/renderer/src/components/agent/AgentCombobox.tsx— full reference implementation of the controlled cmdk pattern.src/renderer/src/components/agent/agent-combobox-command-state.ts—createAgentComboboxCommandState,resolveAgentComboboxCommandState,updateAgentComboboxCommandValue.src/renderer/src/components/settings/MobilePairingQrSection.tsx— consumer ofselectedAddress; unchanged.
Out of scope: mobile-side endpoint override (mobile/app/pair-scan.tsx); main-process code; persistent storage of custom addresses; IPv6, ports, non-Tailscale hostnames.
Task 1: Add parseManualNetworkAddress (TDD)
Files:
-
Create:
src/shared/network/manual-address.ts -
Create:
src/shared/network/manual-address.test.ts -
Step 1: Confirm no existing call site for
mergeForSelectRun:
grep -rn "mergeForSelect" src/ docs/ 2>/dev/nullExpected: no matches. (If there are matches, stop and update Task 2 to keep
mergeForSelectand only addbuildComboboxEntriesalongside it.) -
Step 2: Write the failing tests
Create
src/shared/network/manual-address.test.tswith this exact content:import { describe, it, expect } from 'vitest' import { parseManualNetworkAddress } from './manual-address' describe('parseManualNetworkAddress', () => { describe('IPv4', () => { it('accepts canonical IPv4', () => { expect(parseManualNetworkAddress('192.168.1.24')).toEqual({ ok: true, address: '192.168.1.24' }) expect(parseManualNetworkAddress('100.64.1.20')).toEqual({ ok: true, address: '100.64.1.20' }) }) it('accepts boundary IPv4 values', () => { expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true) }) it('rejects malformed IPv4', () => { for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) { expect(parseManualNetworkAddress(bad)).toEqual({ ok: false, error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' }) } }) it('rejects leading zeros in octets', () => { expect(parseManualNetworkAddress('01.02.03.04')).toEqual({ ok: false, error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' }) expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) }) }) describe('Tailscale MagicDNS hostname', () => { it('accepts short MagicDNS names', () => { expect(parseManualNetworkAddress('my-mac.ts.net')).toEqual({ ok: true, address: 'my-mac.ts.net' }) }) it('accepts tailnet-qualified MagicDNS names', () => { expect(parseManualNetworkAddress('my-mac.tail-abcd.ts.net')).toEqual({ ok: true, address: 'my-mac.tail-abcd.ts.net' }) expect(parseManualNetworkAddress('a.b.c.d.ts.net').ok).toBe(true) }) it('is case-insensitive', () => { expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true) }) it('rejects non-Tailscale hostnames', () => { for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) { expect(parseManualNetworkAddress(bad).ok).toBe(false) } }) }) describe('length and whitespace', () => { it('rejects inputs longer than 253 chars', () => { const long = `${'a'.repeat(250)}.ts.net` expect(long.length).toBeGreaterThan(253) expect(parseManualNetworkAddress(long).ok).toBe(false) }) it('trims leading and trailing whitespace before validating', () => { expect(parseManualNetworkAddress(' 192.168.1.24 ')).toEqual({ ok: true, address: '192.168.1.24' }) }) }) }) -
Step 3: Run tests to verify they fail
Run from repo root:
pnpm vitest run src/shared/network/manual-address.test.tsExpected: error like
Cannot find module './manual-address'. This is the failing-test step — do not skip it. -
Step 4: Implement
parseManualNetworkAddressCreate
src/shared/network/manual-address.tswith this exact content:// Why: pure shared helper so the same validation runs in renderer // today and in any future CLI/main-process caller without duplicating // the IPv4 + Tailscale MagicDNS grammar. const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])' const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}` // MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net. // Labels may not start or end with a hyphen; max 63 chars per label (DNS limit). const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net` const HOSTNAME_MAX_LENGTH = 253 const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' export type ParseManualAddressResult = | { ok: true; address: string } | { ok: false; error: string } export function parseManualNetworkAddress(input: string): ParseManualAddressResult { const trimmed = input.trim() if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) { return { ok: false, error: ERROR_MESSAGE } } if (/\s/.test(trimmed)) { return { ok: false, error: ERROR_MESSAGE } } const ipv4Regex = new RegExp(`^${IPV4}$`) if (ipv4Regex.test(trimmed)) { return { ok: true, address: trimmed } } const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i') if (magicRegex.test(trimmed)) { return { ok: true, address: trimmed } } return { ok: false, error: ERROR_MESSAGE } } -
Step 5: Run tests to verify they pass
Run:
pnpm vitest run src/shared/network/manual-address.test.tsExpected: all tests pass.
-
Step 6: Lint and typecheck
pnpm lint src/shared/network/ pnpm typecheckExpected: no errors. Fix any and re-run before continuing.
-
Step 7: Commit
git add src/shared/network/manual-address.ts src/shared/network/manual-address.test.ts git commit -m "feat(mobile-pairing): add parseManualNetworkAddress validator"
Task 2: Replace mergeForSelect with buildComboboxEntries (TDD)
Files:
-
Modify:
src/renderer/src/components/settings/mobile-network-interface-selection.ts -
Modify:
src/renderer/src/components/settings/mobile-network-interface-selection.test.ts -
Step 1: Read the current contents of both files
Read the full source of:
src/renderer/src/components/settings/mobile-network-interface-selection.tssrc/renderer/src/components/settings/mobile-network-interface-selection.test.ts
Confirm:
mergeForSelectis the only exported function besides theMobileNetworkInterfacetype andselectRefreshedNetworkAddress. IfselectRefreshedNetworkAddressis called fromMobileNetworkInterfaceSection.tsx, keep it. -
Step 2: Write the failing tests for
buildComboboxEntriesReplace the entire body of
mobile-network-interface-selection.test.tswith:import { describe, it, expect } from 'vitest' import { buildComboboxEntries, selectRefreshedNetworkAddress, type MobileNetworkInterface } from './mobile-network-interface-selection' const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } describe('buildComboboxEntries', () => { it('returns only interface entries when query is empty', () => { const entries = buildComboboxEntries([LAN, TAILNET], '') expect(entries).toEqual([ { kind: 'interface', iface: LAN }, { kind: 'interface', iface: TAILNET } ]) }) it('filters interfaces by substring on address or name (case-insensitive)', () => { const entries = buildComboboxEntries([LAN, TAILNET], 'TAIL') expect(entries).toEqual([{ kind: 'interface', iface: TAILNET }]) }) it('appends a use-query entry when the query is a valid address not in the list', () => { const entries = buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net') expect(entries).toEqual([ { kind: 'interface', iface: LAN }, { kind: 'interface', iface: TAILNET }, { kind: 'use-query', address: 'my-mac.tail-abcd.ts.net' } ]) }) it('suppresses use-query when query equals an existing interface address', () => { const entries = buildComboboxEntries([LAN, TAILNET], '100.64.1.20') expect(entries).toEqual([ { kind: 'interface', iface: LAN }, { kind: 'interface', iface: TAILNET } ]) }) it('returns only use-query when interfaces are empty and query is valid', () => { const entries = buildComboboxEntries([], '1.2.3.4') expect(entries).toEqual([{ kind: 'use-query', address: '1.2.3.4' }]) }) it('omits use-query when query is invalid', () => { const entries = buildComboboxEntries([LAN, TAILNET], 'not-an-address') expect(entries).toEqual([ { kind: 'interface', iface: LAN }, { kind: 'interface', iface: TAILNET } ]) }) }) describe('selectRefreshedNetworkAddress', () => { // Existing behavior is preserved verbatim from the spec. it('keeps the selected address when refresh discovers a new tailnet interface', () => { expect(selectRefreshedNetworkAddress(LAN.address, [LAN, TAILNET])).toBe(LAN.address) }) it('selects the first refreshed interface when there is no current address', () => { expect(selectRefreshedNetworkAddress(undefined, [TAILNET, LAN])).toBe(TAILNET.address) }) it('prefers a tailnet address when no address is selected yet', () => { expect(selectRefreshedNetworkAddress(undefined, [LAN, TAILNET])).toBe(TAILNET.address) }) it('moves to the first refreshed interface when the current address disappeared', () => { expect(selectRefreshedNetworkAddress('10.0.0.4', [TAILNET, LAN])).toBe(TAILNET.address) }) it('moves to a tailnet address when the current address disappeared', () => { expect(selectRefreshedNetworkAddress('10.0.0.4', [LAN, TAILNET])).toBe(TAILNET.address) }) it('clears the selection when no interfaces are available', () => { expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined() }) }) -
Step 3: Run the test file to verify the new tests fail
pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.tsExpected:
buildComboboxEntries is not a function(or import error) for the new block; theselectRefreshedNetworkAddressblock still passes. -
Step 4: Rewrite
mobile-network-interface-selection.tsReplace the entire file with:
import { isTailnetIPv4Address } from '../../../../shared/tailnet-address' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' export type MobileNetworkInterface = { name: string address: string } export type ComboboxEntry = | { kind: 'interface'; iface: MobileNetworkInterface } | { kind: 'use-query'; address: string } // Why: the UI needs a single ordered list to render inside CommandList. // Behavior branches on whether the query parses as a valid address — // valid queries show the full interface list (so users can pivot to an // existing interface mid-typing), invalid queries substring-filter and // fall back to the full list when nothing matches. export function buildComboboxEntries( interfaces: readonly MobileNetworkInterface[], query: string ): readonly ComboboxEntry[] { const trimmed = query.trim() if (trimmed === '') { return interfaces.map((iface) => ({ kind: 'interface' as const, iface })) } const parsed = parseManualNetworkAddress(trimmed) let visible: readonly MobileNetworkInterface[] if (parsed.ok) { // Valid address: keep every interface visible. visible = interfaces } else { // Invalid: substring-filter; fall back to full list when nothing matches. const lowered = trimmed.toLowerCase() const filtered = interfaces.filter( (iface) => iface.address.toLowerCase().includes(lowered) || iface.name.toLowerCase().includes(lowered) ) visible = filtered.length > 0 ? filtered : interfaces } const entries: ComboboxEntry[] = visible.map((iface) => ({ kind: 'interface' as const, iface })) if (parsed.ok && !visible.some((iface) => iface.address === parsed.address)) { entries.push({ kind: 'use-query', address: parsed.address }) } return entries } export function selectRefreshedNetworkAddress( currentAddress: string | undefined, interfaces: readonly MobileNetworkInterface[] ): string | undefined { if (interfaces.length === 0) { return undefined } if (currentAddress && interfaces.some((iface) => iface.address === currentAddress)) { return currentAddress } return ( interfaces.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? interfaces[0]!.address ) }The relative import paths (
../../../../shared/tailnet-address,../../../../shared/network/manual-address) must match the file's location in the tree. If they don't resolve, adjust based on the existing path style in the same directory (e.g.../foovs@/foo). -
Step 5: Run the test file to verify everything passes
pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.tsExpected: all tests pass.
-
Step 6: Lint and typecheck
pnpm lint src/renderer/src/components/settings/mobile-network-interface-selection.ts pnpm typecheck -
Step 7: Commit
git add src/renderer/src/components/settings/mobile-network-interface-selection.ts \ src/renderer/src/components/settings/mobile-network-interface-selection.test.ts git commit -m "feat(mobile-pairing): buildComboboxEntries for network interface combobox"
Task 3: Rewrite MobileNetworkInterfaceSection UI to use Popover + Command
Files:
-
Modify:
src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx -
Create:
src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx -
Step 1: Read
AgentCombobox.tsxend-to-endRead
src/renderer/src/components/agent/AgentCombobox.tsxandagent-combobox-command-state.tsin full. You will be borrowing:- The
Popover+Command+CommandInput+CommandList+CommandItemJSX structure. - The controlled
commandState/commandValuepattern that mirrorsAgentCombobox. - The
setInputNode/focusSearchInputpattern for keyboard accessibility.
Do not import agent-specific helpers (e.g.
searchAgentPickerEntries,agent-combobox-command-stateis shared but the search logic isn't needed here). - The
-
Step 2: Read the current
MobileNetworkInterfaceSection.tsxin fullRead the file. Note the props contract:
type MobileNetworkInterfaceSectionProps = { networkInterfaces: MobileNetworkInterface[] selectedAddress: string | undefined onSelectedAddressChange: (address: string) => void refreshingNetworkInterfaces: boolean onRefreshNetworkInterfaces: () => void loading: boolean hasQrCode: boolean onGenerateQr: () => void }The contract must not change — only the inner control does.
MobilePairingQrSection.tsxconsumesselectedAddressand is unaffected. -
Step 3: Write the failing render tests
Create
src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx:import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' import type { MobileNetworkInterface } from './mobile-network-interface-selection' const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } function renderSection(overrides: Partial<React.ComponentProps<typeof MobileNetworkInterfaceSection>> = {}) { const onSelectedAddressChange = vi.fn() const onRefreshNetworkInterfaces = vi.fn() const onGenerateQr = vi.fn() const props: React.ComponentProps<typeof MobileNetworkInterfaceSection> = { networkInterfaces: [LAN, TAILNET], selectedAddress: TAILNET.address, onSelectedAddressChange, refreshingNetworkInterfaces: false, onRefreshNetworkInterfaces, loading: false, hasQrCode: false, onGenerateQr, ...overrides } const user = userEvent.setup() const utils = render(<MobileNetworkInterfaceSection {...props} />) return { ...utils, user, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerateQr } } describe('MobileNetworkInterfaceSection', () => { it('renders the trigger with the currently selected address', () => { renderSection() expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') }) it('lets the user type a custom address and confirms via the Use row', async () => { const { user, onSelectedAddressChange } = renderSection() await user.click(screen.getByRole('combobox')) const input = screen.getByPlaceholderText(/search or type/i) await user.type(input, 'my-mac.tail-abcd.ts.net') await user.click(screen.getByRole('option', { name: /Use "my-mac\.tail-abcd\.ts\.net"/ })) expect(onSelectedAddressChange).toHaveBeenCalledWith('my-mac.tail-abcd.ts.net') }) it('shows an inline error and no Use row when the query is invalid', async () => { const { user } = renderSection() await user.click(screen.getByRole('combobox')) await user.type(screen.getByPlaceholderText(/search or type/i), 'not an address') expect(screen.getByText(/Enter an IPv4 address or Tailscale MagicDNS hostname/i)).toBeInTheDocument() expect(screen.queryByRole('option', { name: /Use / })).not.toBeInTheDocument() }) it('suppresses the Use row when the typed address matches an existing interface', async () => { const { user } = renderSection() await user.click(screen.getByRole('combobox')) await user.type(screen.getByPlaceholderText(/search or type/i), '192.168.1.24') expect(screen.getByRole('option', { name: '192.168.1.24 (en0)' })).toBeInTheDocument() expect(screen.queryByRole('option', { name: /Use "192\.168\.1\.24"/ })).not.toBeInTheDocument() }) it('renders the (custom) label on the trigger after a custom selection', () => { renderSection({ selectedAddress: 'my-mac.tail-abcd.ts.net' }) expect(screen.getByRole('combobox')).toHaveTextContent('my-mac.tail-abcd.ts.net (custom)') }) it('shows No interfaces found when the list is empty', () => { renderSection({ networkInterfaces: [], selectedAddress: undefined }) expect(screen.getByRole('combobox')).toHaveTextContent(/no interfaces found/i) }) }) -
Step 4: Run the test file to verify it fails
pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsxExpected: failures on every assertion — the component still uses
Select. This is the failing-test step. -
Step 5: Rewrite the component
Replace the entire body of
MobileNetworkInterfaceSection.tsxwith:import { useCallback, useMemo, useState } from 'react' import { ChevronDown, ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' import { Button } from '../ui/button' import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, CommandSeparator } from '../ui/command' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' import { translate } from '@/i18n/i18n' import { buildComboboxEntries, type MobileNetworkInterface } from './mobile-network-interface-selection' const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' const TRIGGER_LABEL_CUSTOM = 'custom' const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' type MobileNetworkInterfaceSectionProps = { networkInterfaces: MobileNetworkInterface[] selectedAddress: string | undefined onSelectedAddressChange: (address: string) => void refreshingNetworkInterfaces: boolean onRefreshNetworkInterfaces: () => void loading: boolean hasQrCode: boolean onGenerateQr: () => void } function formatInterfaceLabel(iface: MobileNetworkInterface): string { return `${iface.address} (${iface.name})` } function isCustomLabel(name: string): boolean { return name === TRIGGER_LABEL_CUSTOM } export function MobileNetworkInterfaceSection({ networkInterfaces, selectedAddress, onSelectedAddressChange, refreshingNetworkInterfaces, onRefreshNetworkInterfaces, loading, hasQrCode, onGenerateQr }: MobileNetworkInterfaceSectionProps): React.JSX.Element { const [open, setOpen] = useState(false) const [query, setQuery] = useState('') // Why: session-scoped custom selection (see spec). Cleared automatically // when the settings pane unmounts because the state lives in this component. const [customAddress, setCustomAddress] = useState<string | null>(null) const selectedIface = useMemo<MobileNetworkInterface | null>(() => { if (!selectedAddress) return null const matched = networkInterfaces.find((iface) => iface.address === selectedAddress) if (matched) return matched if (selectedAddress === customAddress) { return { name: TRIGGER_LABEL_CUSTOM, address: selectedAddress } } return null }, [networkInterfaces, selectedAddress, customAddress]) const triggerLabel = selectedIface ? formatInterfaceLabel(selectedIface) : translate( 'auto.components.settings.MobileNetworkInterfaceSection.b2c384cfd6', 'No interfaces found' ) const entries = useMemo( () => buildComboboxEntries(networkInterfaces, query), [networkInterfaces, query] ) const queryParse = useMemo(() => parseManualNetworkAddress(query), [query]) const showInlineError = query.trim() !== '' && !queryParse.ok const handleSelectInterface = useCallback( (iface: MobileNetworkInterface) => { setCustomAddress(null) setQuery('') setOpen(false) onSelectedAddressChange(iface.address) }, [onSelectedAddressChange] ) const handleSelectUseQuery = useCallback( (address: string) => { setCustomAddress(address) setQuery('') setOpen(false) onSelectedAddressChange(address) }, [onSelectedAddressChange] ) return ( <div className="rounded-lg border border-border/60 p-4"> <div className="mb-3 flex items-center gap-2"> <Wifi className="size-4 text-muted-foreground" /> <span className="text-sm font-medium"> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.406a35121c', 'Network Interface' )} </span> </div> <p className="text-muted-foreground mb-3 text-xs"> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.d536b5e20d', 'Choose which network address to advertise in the QR code. Use your LAN address for same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network access.' )} </p> <div className="space-y-3"> <div className="flex flex-wrap items-center gap-3"> <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button type="button" variant="outline" size="sm" role="combobox" aria-expanded={open} className="min-w-[220px] justify-between font-normal" > <span className="truncate">{triggerLabel}</span> <ChevronDown className="ml-2 size-3.5 opacity-60" /> </Button> </PopoverTrigger> <PopoverContent className="w-[320px] p-0" align="start"> <Command shouldFilter={false}> <CommandInput value={query} onValueChange={setQuery} placeholder={translate( 'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-placeholder', 'Search or type an address…' )} /> <CommandList> <CommandEmpty> {showInlineError ? ERROR_MESSAGE : translate( 'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-empty', 'No matching interfaces' )} </CommandEmpty> {entries.map((entry, index) => { if (entry.kind === 'interface') { return ( <CommandItem key={`iface-${entry.iface.name}-${entry.iface.address}`} value={`${entry.iface.address} ${entry.iface.name}`} onSelect={() => handleSelectInterface(entry.iface)} > {formatInterfaceLabel(entry.iface)} </CommandItem> ) } const isFirstUseQuery = index > 0 return ( <div key={`use-${entry.address}`}> {isFirstUseQuery ? <CommandSeparator /> : null} <CommandItem value={`__use__ ${entry.address}`} onSelect={() => handleSelectUseQuery(entry.address)} > Use "{entry.address}" </CommandItem> </div> ) })} </CommandList> </Command> </PopoverContent> </Popover> <Tooltip> <TooltipTrigger asChild> <Button type="button" variant="ghost" size="icon-sm" onClick={onRefreshNetworkInterfaces} disabled={refreshingNetworkInterfaces} aria-label={translate( 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d', 'Refresh network interfaces' )} className="text-muted-foreground" > <RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} /> </Button> </TooltipTrigger> <TooltipContent side="bottom" sideOffset={6}> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d', 'Refresh network interfaces' )} </TooltipContent> </Tooltip> </div> {showInlineError ? ( <p className="text-xs text-statusRed" role="status"> {ERROR_MESSAGE} </p> ) : null} <Button onClick={onGenerateQr} disabled={loading || !selectedAddress} size="sm" className="gap-1.5" > {loading ? ( <Loader2 className="size-3.5 animate-spin" /> ) : hasQrCode ? ( <RefreshCw className="size-3.5" /> ) : ( <QrCode className="size-3.5" /> )} {hasQrCode ? translate( 'auto.components.settings.MobileNetworkInterfaceSection.1e64659126', 'Regenerate' ) : translate( 'auto.components.settings.MobileNetworkInterfaceSection.c541f67790', 'Generate QR Code' )} </Button> </div> <Accordion type="single" collapsible className="mt-4 border-t border-border/60 pt-2"> <AccordionItem value="remote-pairing-guide"> <AccordionTrigger className="py-2 text-xs"> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9', 'Connect outside your Wi-Fi with a tailnet' )} </AccordionTrigger> <AccordionContent className="space-y-3 text-xs text-muted-foreground"> <p> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff', 'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.' )} </p> <ol className="list-decimal space-y-1 pl-4"> <li> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb', 'Install' )}{' '} <button type="button" onClick={() => void window.api.shell.openUrl(TAILSCALE_DOWNLOAD_URL)} className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline" > {translate( 'auto.components.settings.MobileNetworkInterfaceSection.1dc87a7fbc', 'Tailscale' )} <ExternalLink className="size-3" /> </button>{' '} {translate( 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a', 'on your computer and phone.' )} </li> <li> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a', 'Sign in to the same tailnet on both devices.' )} </li> <li> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5', 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.' )} </li> <li> {translate( 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e', 'Regenerate the QR code and scan it from the Orca mobile app.' )} </li> </ol> </AccordionContent> </AccordionItem> </Accordion> </div> ) }Notes for the implementer:
Command shouldFilter={false}because we already filter inbuildComboboxEntries. Without this, cmdk's default fuzzy filter would re-filter the manually-injected__use__row out of view.- The
valueprop on eachCommandItemkeeps cmdk happy but does not affect our filtering; we own filtering. useMemois used forselectedIface,entries, andqueryParseso re-renders triggered by parent prop changes don't churn the combobox state.- If
isCustomLabelis unused after a lint pass (no caller in the final file), delete the helper — the spec lists it but the implementation inlines the check.
-
Step 6: Run the new test file
pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsxExpected: all six tests pass.
If any fail:
toHaveTextContentmismatches → check the trigger label format.getByRole('option', { name: /Use "…"/ })not found → verifyCommand shouldFilter={false}is set.- Inline error not appearing → verify
showInlineErroris computed againstqueryParse.ok.
-
Step 7: Run the full test suite for the settings folder
pnpm vitest run src/renderer/src/components/settings/Expected: all green. Confirm nothing else broke (e.g.
MobilePairingQrSection.test.tsxif it exists). -
Step 8: Lint, typecheck, build
pnpm lint pnpm typecheck pnpm buildExpected: all green. If
pnpm lintcomplains about an unused import (e.g.isCustomLabelorCommandSeparator), remove it and re-run. -
Step 9: Commit
git add src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx \ src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx git commit -m "feat(mobile-pairing): combobox with manual address entry"
Task 4: End-to-end verification & PR prep
Files: none modified; verification only.
-
Step 1: Run the four CI checks locally
pnpm lint pnpm typecheck pnpm test pnpm buildExpected: all green. This is exactly what
.github/workflows/runs. -
Step 2: Manual smoke check
If Orca can be launched locally with the desktop build:
- Open Settings → Mobile.
- Click the Network Interface dropdown — confirm both interfaces list.
- Type a tailnet address — confirm the
Use "…"row appears. - Click
Use "…"— confirm the trigger switches to… (custom). - Click Refresh — confirm the custom selection survives.
- Type an invalid string — confirm the inline error appears and no
Use …row is shown. - Type an address that already matches an interface (e.g.
192.168.1.24) — confirm theUserow is hidden and selecting the interface row does NOT show(custom).
Skip any step that requires a running desktop build and note it in the PR description.
-
Step 3: Capture before/after screenshots
Save screenshots to a temp directory (NOT to the repo —
CONTRIBUTING.mdandAGENTS.mdforbid committing PR evidence images). Use gstack browse if running the production binary:$HOME/.claude/skills/gstack/browse/dist/browse screenshot /tmp/orca-mobile-section-before.png --selector ...Attach the screenshots to the PR conversation (never use
gh-attach). -
Step 4: Push the branch and draft the PR
git push origin HEAD gh pr create \ --title "feat(mobile-pairing): combobox with manual network address entry" \ --body-file /tmp/pr-body.mdThe PR body should follow
pull_request_template.md:- Summary: explain that the desktop Network Interface dropdown now accepts a manually-typed IPv4 or Tailscale MagicDNS hostname.
- Screenshots: link to the conversation-attached screenshots.
- Testing: check all four
pnpmboxes; note thatpnpm testincludes the three new test files (manual-address.test.ts,mobile-network-interface-selection.test.ts,MobileNetworkInterfaceSection.test.tsx). - AI Review Report: confirm the review checked cross-platform (no platform-specific code; uses shadcn primitives + cmdk patterns already in the codebase), SSH/remote/local compatibility (no change to main-process IPC), agent/integration compatibility (no change), performance (UI re-render cost is bounded by
useMemoover the interface list), UI quality (follows STYLEGUIDE.md; usestext-statusRedfor the error), security (no new IPC; the address flows through the existingselectedAddressprop whichMobilePairingQrSectionalready validated). - Security Audit: no new IPC, no new auth surface, no new dependency, no new env var. The parser rejects malformed input early so the QR endpoint never sees invalid data.
- Notes: none — single platform-agnostic renderer change.
- X handle: include the contributor's X handle per CONTRIBUTING.md so maintainers can shout out.
-
Step 5: Request review
gh pr edit --add-reviewer <maintainer-handle>Or comment
@<maintainer>in the PR if reviewer auto-assignment isn't available.
Definition of done
- All four
pnpmchecks pass locally. - All new and existing tests pass.
- The component renders correctly in a smoke test (or smoke-test steps are documented as skipped in the PR).
- The PR is open with a body matching
pull_request_template.md. - The contributor's X handle is in the PR description.