From 299bc421e22bf3a2c9c4dae95a96066aae3f09eb Mon Sep 17 00:00:00 2001
From: ppw-stack <58170323+ppw-stack@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:32:36 +0800
Subject: [PATCH] feat(mobile-pairing): combobox with manual network address
entry (#6501)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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 ` ` + `` 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 ""` 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
---------
Co-authored-by: ppw-stack
Co-authored-by: Jinwoo-H
Co-authored-by: Orca
---
...ca-mobile-manual-network-address-design.md | 193 ++++
...6-27-orca-mobile-manual-network-address.md | 972 ++++++++++++++++++
package.json | 3 +
pnpm-lock.yaml | 160 +++
src/main/wsl-unc-delete.test.ts | 10 +-
.../mobile/CustomNetworkAddressDialog.tsx | 113 ++
.../src/components/mobile/MobileHero.tsx | 40 +-
.../src/components/mobile/MobilePage.tsx | 28 +-
.../mobile/NetworkInterfacePicker.tsx | 119 +++
.../MobileNetworkInterfaceSection.test.tsx | 148 +--
.../MobileNetworkInterfaceSection.tsx | 34 +-
...mobile-network-interface-selection.test.ts | 18 +-
.../mobile-network-interface-selection.ts | 16 +-
src/renderer/src/components/ui/command.tsx | 5 +-
src/renderer/src/i18n/locales/en.json | 14 +
src/renderer/src/i18n/locales/es.json | 14 +
src/renderer/src/i18n/locales/ja.json | 14 +
src/renderer/src/i18n/locales/ko.json | 14 +
src/renderer/src/i18n/locales/zh.json | 14 +
src/shared/network/manual-address.test.ts | 81 ++
src/shared/network/manual-address.ts | 36 +
21 files changed, 1909 insertions(+), 137 deletions(-)
create mode 100644 docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md
create mode 100644 docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md
create mode 100644 src/renderer/src/components/mobile/CustomNetworkAddressDialog.tsx
create mode 100644 src/renderer/src/components/mobile/NetworkInterfacePicker.tsx
create mode 100644 src/shared/network/manual-address.test.ts
create mode 100644 src/shared/network/manual-address.ts
diff --git a/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md b/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md
new file mode 100644
index 000000000..fdcc7987a
--- /dev/null
+++ b/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md
@@ -0,0 +1,193 @@
+# Design: Manual network address entry for Orca desktop mobile pairing
+
+**Date:** 2026-06-27
+**Scope:** Desktop renderer (Settings → Mobile → Network Interface section)
+**Status:** Draft, awaiting user review
+
+## Problem
+
+`src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` lets the user pick the network address that gets baked into the mobile-pairing QR code. Today the only options come from `networkInterfaces`, which is the list returned by the main process enumerating OS network interfaces (`en0`, `tailscale0`, etc.). If a user wants a tailnet address that the OS hasn't surfaced yet — a Tailscale MagicDNS hostname, a ZeroTier-assigned address not yet visible to the OS, or a manual LAN IP — they have no way to type one in. The QR ends up pointing at an interface the phone cannot actually reach.
+
+## Decision summary
+
+Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. The popover contains a single `CommandInput` that filters the auto-discovered interfaces above and renders a special "Use …" entry at the bottom of the list whenever the input is a valid IPv4 address or Tailscale MagicDNS hostname. Picking that entry selects a custom address; the trigger shows ` (custom)`. Custom addresses are session-scoped (cleared when the settings pane closes).
+
+## Constraints (from `CONTRIBUTING.md` + `AGENTS.md`)
+
+- Cross-platform: code paths must not assume a single platform; the manual entry path itself is platform-neutral.
+- No `helpers`/`utils`/`misc` file names; use concrete names.
+- No `eslint-disable max-lines`; split files instead.
+- Prefer `.ts` over `.d.ts`.
+- UI work follows `docs/STYLEGUIDE.md` and uses shadcn primitives from `src/renderer/src/components/ui/`.
+- The renderer ↔ shared boundary is `src/shared/`; pure logic that may be reused outside the renderer goes there.
+- Comments explain *why*, briefly.
+
+## Files
+
+| Path | Change |
+| --- | --- |
+| `src/shared/network/manual-address.ts` | **New.** Pure `parseManualNetworkAddress(input)` returning a discriminated union. |
+| `src/shared/network/manual-address.test.ts` | **New.** Vitest cases for IPv4 and MagicDNS hostname validation. |
+| `src/renderer/src/components/settings/mobile-network-interface-selection.ts` | Replace `mergeForSelect` with `buildComboboxEntries(interfaces, customAddress)` returning the entry list the UI maps over. |
+| `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` | Replace `mergeForSelect` tests with `buildComboboxEntries` tests. |
+| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` | Swap `Select` for `Popover + Command`; add `open`/`query`/`customAddress` state. |
+| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` | **New.** Render tests via `@testing-library/react`. |
+
+No changes to: `mobile/app/pair-scan.tsx`, `MobilePairingQrSection.tsx`, `use-mobile-install-qr.ts`, or any main-process code. The QR generation pipeline already consumes `selectedAddress: string`, which is all the new flow produces.
+
+## Module 1: `parseManualNetworkAddress`
+
+```ts
+// src/shared/network/manual-address.ts
+export type ParseManualAddressResult =
+ | { ok: true; address: string }
+ | { ok: false; error: string }
+
+export function parseManualNetworkAddress(input: string): ParseManualAddressResult
+```
+
+**Rules** (in order):
+
+1. `input.trim()` must be non-empty. Otherwise `{ ok: false, error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' }`.
+2. Reject any input containing whitespace anywhere; reject any input longer than 253 chars (DNS hostname cap).
+3. Accept if it matches the IPv4 grammar (four dotted octets, each 0–255). No leading zeros except for `0` itself.
+4. Accept if it matches the Tailscale MagicDNS hostname grammar:
+ - Regex (case-insensitive): `/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.ts\.net$/`
+5. Otherwise return the same error as (1).
+
+Pure function, no React, no I/O. Unit-testable in isolation.
+
+## Module 2: `buildComboboxEntries`
+
+```ts
+// src/renderer/src/components/settings/mobile-network-interface-selection.ts
+export type MobileNetworkInterface = { name: string; address: string }
+
+export type ComboboxEntry =
+ | { kind: 'interface'; iface: MobileNetworkInterface }
+ | { kind: 'use-query'; address: string } // only emitted when current query is valid
+
+export function buildComboboxEntries(
+ interfaces: readonly MobileNetworkInterface[],
+ query: string
+): readonly ComboboxEntry[]
+```
+
+**Behavior:**
+
+- Trim `query`; if empty, return only `kind: 'interface'` entries from `interfaces` (no `use-query`).
+- If `query` is non-empty, behavior branches on `parseManualNetworkAddress(query)`:
+ - **Valid query:** skip substring filtering and keep every interface visible (so the user can pivot to an existing interface mid-typing). Emit each as `kind: 'interface'`.
+ - **Invalid query:** filter `interfaces` by case-insensitive substring match on `iface.address` OR `iface.name`. Emit each as `kind: 'interface'`. If the filter yields zero matches, fall back to the full `interfaces` list (so the user always sees the available options, never an empty list mid-typing).
+- After the interface entries, if the query parsed as valid AND no emitted interface has an `address` exactly equal to `parsed.address`, append `{ kind: 'use-query', address: parsed.address }`. (Suppression happens regardless of whether filtering ran, because valid queries skip filtering entirely — the check is against the visible interface list, which for valid queries is the full list.)
+- Order: interface entries first (stable, in input order — either filtered or the full list per the branch above), then the optional `use-query`.
+- The `selectRefreshedNetworkAddress` function is **kept** unchanged — it's still the rule that decides the *initial* `selectedAddress` when no manual entry exists. The UI calls it on mount and on Refresh; afterwards, the combobox owns the selection.
+
+## Module 3: `MobileNetworkInterfaceSection` UI
+
+Outer JSX (header text, description, Generate QR button, Refresh button, Tailnet accordion) is untouched. Only the inner selection control is replaced.
+
+**State:**
+
+```ts
+const [open, setOpen] = useState(false)
+const [query, setQuery] = useState('')
+const [customAddress, setCustomAddress] = useState(null)
+```
+
+`customAddress` is the last value the user confirmed via the `use-query` entry. It is the "session-scoped" custom selection that the trigger displays with the `(custom)` label. It is reset when the settings pane unmounts (React unmount handles this automatically; no global store involved).
+
+**Trigger:** A `Button` styled like the existing `SelectTrigger` (`min-w-[220px]`, `size="sm"`). Label uses the same `formatInterfaceLabel` helper:
+
+```ts
+function formatInterfaceLabel(iface: { name: string; address: string }): string {
+ return `${iface.address} (${iface.name})`
+}
+```
+
+For the custom selection the rendered iface is `{ name: 'custom', address: customAddress }`, so the trigger shows `100.64.1.20 (custom)`.
+
+**Popover body:** `Command` containing:
+
+- `CommandInput` with placeholder `Search or type an address…` and `value={query}` / `onValueChange={setQuery}`.
+- `CommandList` containing:
+ - `CommandEmpty` shown only when no interfaces match AND `parseManualNetworkAddress(query)` is invalid (i.e., truly nothing to pick).
+ - One `CommandItem` per `kind: 'interface'` entry from `buildComboboxEntries(networkInterfaces, query)`. `onSelect` calls `onSelectedAddressChange(iface.address)`, `setCustomAddress(null)`, `setQuery('')`, `setOpen(false)`.
+ - Optional visual separator (e.g., `CommandSeparator`) before the `use-query` entry.
+ - One `CommandItem` for `kind: 'use-query'` (only present when query is valid). Label: `Use ""`. `onSelect` calls `onSelectedAddressChange(address)`, `setCustomAddress(address)`, `setQuery('')`, `setOpen(false)`.
+
+**Controlled cmdk selection:** Copy the controlled-`commandValue` pattern from `src/renderer/src/components/agent/AgentCombobox.tsx` (imports `createAgentComboboxCommandState`, `resolveAgentComboboxCommandState`, `updateAgentComboboxCommandValue` from `@/components/agent/agent-combobox-command-state`, plus the `Command`, `CommandEmpty`, `CommandInput`, `CommandItem`, `CommandList` primitives from `@/components/ui/command`) so that hovering the footer doesn't leave a stale highlight on a list item. The exact state shape will be minimal — only one list, no footer-group complexity — so the borrowed helpers are sufficient. No new helpers are introduced in this design.
+
+**Validation feedback:**
+
+- When `query` is non-empty and invalid, render a one-line `text-xs text-statusRed` message directly below the trigger: `"Enter an IPv4 address or Tailscale MagicDNS hostname"`. Use the existing `statusRed` token from the theme to stay style-guide compliant.
+- The `use-query` entry only appears when valid; no need to disable it.
+
+**Refresh button:** Unchanged. Calls `onRefreshNetworkInterfaces`. The combobox re-renders with the new `networkInterfaces`; `query` and `customAddress` are preserved (user might be mid-typing).
+
+**Generate QR button:** Unchanged. Disabled when `!selectedAddress`. No new branches.
+
+## Data flow
+
+```
+networkInterfaces (prop, refreshed by parent)
+ │
+ ▼
+buildComboboxEntries(networkInterfaces, query)
+ │
+ ▼
+CommandList rows
+ │
+ ▼ onSelect
+onSelectedAddressChange(string) ─► parent re-renders MobilePairingQrSection
+ ─► QR is regenerated with new endpoint
+```
+
+The parent of `MobileNetworkInterfaceSection` (whichever Settings tab owns it) already maintains `selectedAddress` and re-passes it down. This design does not change that contract.
+
+## Edge cases
+
+1. **Duplicate manual entry vs. existing interface** — `buildComboboxEntries` suppresses the `use-query` entry whenever the parsed query exactly equals an emitted interface's `address`. For valid queries the visible list is the full interface list (no substring filter runs), so the suppression check is against every interface, not just filtered ones. The user lands on the existing interface row instead of a duplicate.
+2. **OS discovers the manual address later** — If `customAddress === '100.64.1.20'` and a refresh surfaces `100.64.1.20 (tailscale0)`, both are valid options; the user's selection stays. A future iteration may add a "merge" action; out of scope here.
+3. **Empty `networkInterfaces`** — All-interface list is empty. If `query` is also empty, `CommandEmpty` shows. If `query` is valid, the `use-query` entry still appears so the user can type an address even when nothing is enumerated. The trigger shows `No interfaces found`.
+4. **Manual address becomes unreachable at pair time** — Not handled here. The QR generation succeeds; `pair-scan.tsx` already surfaces "Cannot connect — same network?" on failure.
+5. **Closing the popover with an invalid query typed** — `customAddress` and `selectedAddress` are unchanged. Next open starts with an empty `query`.
+
+## Testing
+
+**`src/shared/network/manual-address.test.ts`**
+
+- Accepts: `0.0.0.0`, `255.255.255.255`, `192.168.1.24`, `100.64.1.20`.
+- Rejects: `''`, `' '`, `'1.2.3'`, `'1.2.3.4.5'`, `'256.0.0.1'`, `'01.02.03.04'` (leading zeros), `'192.168.1.24 '` (trailing space).
+- Accepts MagicDNS: `my-mac.ts.net`, `my-mac.tail-abcd.ts.net`, `a.b.c.d.ts.net`.
+- Rejects MagicDNS: `my-mac` (no `.ts.net`), `my-mac.ts.com`, `-foo.ts.net`, `MY-MAC.TS.NET` is accepted (case-insensitive).
+- Rejects anything > 253 chars; rejects whitespace anywhere.
+- Pure unit tests; no React, no mocks.
+
+**`mobile-network-interface-selection.test.ts`**
+
+- `buildComboboxEntries([LAN, TAILNET], '')` returns two interface entries, no `use-query`.
+- `buildComboboxEntries([LAN, TAILNET], '100')` (invalid query) returns the tailnet interface only — substring filter on `100.64.1.20` matches `100` — and no `use-query` because the query did not parse.
+- `buildComboboxEntries([LAN, TAILNET], '100.64.1.20')` (valid query) returns both interface entries (valid queries skip substring filtering) AND suppresses `use-query` because the parsed address equals an existing interface's `address`.
+- `buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net')` (valid query) returns both interface entries (valid queries skip substring filtering) plus a `use-query` with the trimmed address.
+- `buildComboboxEntries([], '1.2.3.4')` returns just `use-query`.
+
+**`MobileNetworkInterfaceSection.test.tsx`** (new)
+
+- Open popover, type `100.64.1.20`, click `Use "100.64.1.20"` → trigger label becomes `100.64.1.20 (custom)`, query clears.
+- Type `not-an-address` → error message renders, no `Use …` row.
+- Type `192.168.1.24` (matches `en0`) → no `Use …` row; clicking the existing interface selects it as `en0`, not `custom`.
+- Trigger label `No interfaces found` shown when `networkInterfaces` is empty and no manual selection.
+
+## Out of scope
+
+- Persistent storage of manual addresses across sessions (user explicitly chose session-scoped).
+- IPv6, port suffixes, non-Tailscale hostnames (rejected by the parser).
+- Mobile-side endpoint override (separate flow; see `mobile/app/pair-scan.tsx`).
+- Main-process changes — the renderer has enough information already.
+
+## Open questions for reviewer
+
+1. Should the error message stay in English-only here, or get the same `translate('auto.…', 'fallback')` wrap as the rest of the section? Recommend: wrap it for consistency, since the rest of the component already uses `translate()`.
+2. Should `customAddress` survive a "Refresh" click? Recommend yes — user might be mid-typing during a VPN reconnect. Confirmed in Edge case 5 above.
+3. Should `CommandInput` accept paste of a multi-line string (e.g. user pastes `orca://pair?code=…`)? Recommend: no special handling; the existing trim+validate treats it as an invalid address and shows the error. Pair-URL paste remains the path through `pair-scan.tsx`.
diff --git a/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md b/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md
new file mode 100644
index 000000000..c27fe37b2
--- /dev/null
+++ b/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md
@@ -0,0 +1,972 @@
+# 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 ""` 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`](../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` — swap `mergeForSelect` for `buildComboboxEntries` (or add `buildComboboxEntries` and 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` — replace `mergeForSelect` tests with `buildComboboxEntries` tests.
+- `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` — swap `Select` for `Popover + 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 of `selectedAddress`; 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 `mergeForSelect`**
+
+ Run:
+ ```bash
+ grep -rn "mergeForSelect" src/ docs/ 2>/dev/null
+ ```
+ Expected: no matches. (If there are matches, stop and update Task 2 to keep `mergeForSelect` and only add `buildComboboxEntries` alongside it.)
+
+- [ ] **Step 2: Write the failing tests**
+
+ Create `src/shared/network/manual-address.test.ts` with this exact content:
+
+ ```ts
+ 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:
+ ```bash
+ pnpm vitest run src/shared/network/manual-address.test.ts
+ ```
+ Expected: error like `Cannot find module './manual-address'`. This is the failing-test step — do not skip it.
+
+- [ ] **Step 4: Implement `parseManualNetworkAddress`**
+
+ Create `src/shared/network/manual-address.ts` with this exact content:
+
+ ```ts
+ // 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:
+ ```bash
+ pnpm vitest run src/shared/network/manual-address.test.ts
+ ```
+ Expected: all tests pass.
+
+- [ ] **Step 6: Lint and typecheck**
+
+ ```bash
+ pnpm lint src/shared/network/
+ pnpm typecheck
+ ```
+ Expected: no errors. Fix any and re-run before continuing.
+
+- [ ] **Step 7: Commit**
+
+ ```bash
+ 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.ts`
+ - `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts`
+
+ Confirm: `mergeForSelect` is the only exported function besides the `MobileNetworkInterface` type and `selectRefreshedNetworkAddress`. If `selectRefreshedNetworkAddress` is called from `MobileNetworkInterfaceSection.tsx`, keep it.
+
+- [ ] **Step 2: Write the failing tests for `buildComboboxEntries`**
+
+ Replace the entire body of `mobile-network-interface-selection.test.ts` with:
+
+ ```ts
+ 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**
+
+ ```bash
+ pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts
+ ```
+ Expected: `buildComboboxEntries is not a function` (or import error) for the new block; the `selectRefreshedNetworkAddress` block still passes.
+
+- [ ] **Step 4: Rewrite `mobile-network-interface-selection.ts`**
+
+ Replace the entire file with:
+
+ ```ts
+ 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. `../foo` vs `@/foo`).
+
+- [ ] **Step 5: Run the test file to verify everything passes**
+
+ ```bash
+ pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts
+ ```
+ Expected: all tests pass.
+
+- [ ] **Step 6: Lint and typecheck**
+
+ ```bash
+ pnpm lint src/renderer/src/components/settings/mobile-network-interface-selection.ts
+ pnpm typecheck
+ ```
+
+- [ ] **Step 7: Commit**
+
+ ```bash
+ 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.tsx` end-to-end**
+
+ Read `src/renderer/src/components/agent/AgentCombobox.tsx` and `agent-combobox-command-state.ts` in full. You will be borrowing:
+ - The `Popover` + `Command` + `CommandInput` + `CommandList` + `CommandItem` JSX structure.
+ - The controlled `commandState`/`commandValue` pattern that mirrors `AgentCombobox`.
+ - The `setInputNode`/`focusSearchInput` pattern for keyboard accessibility.
+
+ Do not import agent-specific helpers (e.g. `searchAgentPickerEntries`, `agent-combobox-command-state` is shared but the search logic isn't needed here).
+
+- [ ] **Step 2: Read the current `MobileNetworkInterfaceSection.tsx` in full**
+
+ Read the file. Note the props contract:
+ ```ts
+ 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.tsx` consumes `selectedAddress` and is unaffected.
+
+- [ ] **Step 3: Write the failing render tests**
+
+ Create `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx`:
+
+ ```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> = {}) {
+ const onSelectedAddressChange = vi.fn()
+ const onRefreshNetworkInterfaces = vi.fn()
+ const onGenerateQr = vi.fn()
+ const props: React.ComponentProps = {
+ networkInterfaces: [LAN, TAILNET],
+ selectedAddress: TAILNET.address,
+ onSelectedAddressChange,
+ refreshingNetworkInterfaces: false,
+ onRefreshNetworkInterfaces,
+ loading: false,
+ hasQrCode: false,
+ onGenerateQr,
+ ...overrides
+ }
+ const user = userEvent.setup()
+ const utils = render( )
+ 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**
+
+ ```bash
+ pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
+ ```
+ Expected: 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.tsx` with:
+
+ ```tsx
+ 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(null)
+
+ const selectedIface = useMemo(() => {
+ 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 (
+
+
+
+
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.406a35121c',
+ 'Network Interface'
+ )}
+
+
+
+ {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.'
+ )}
+
+
+
+
+
+
+ {triggerLabel}
+
+
+
+
+
+
+
+
+ {showInlineError ? ERROR_MESSAGE : translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-empty',
+ 'No matching interfaces'
+ )}
+
+ {entries.map((entry, index) => {
+ if (entry.kind === 'interface') {
+ return (
+ handleSelectInterface(entry.iface)}
+ >
+ {formatInterfaceLabel(entry.iface)}
+
+ )
+ }
+ const isFirstUseQuery = index > 0
+ return (
+
+ {isFirstUseQuery ? : null}
+ handleSelectUseQuery(entry.address)}
+ >
+ Use "{entry.address}"
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d',
+ 'Refresh network interfaces'
+ )}
+
+
+
+ {showInlineError ? (
+
+ {ERROR_MESSAGE}
+
+ ) : null}
+
+ {loading ? (
+
+ ) : hasQrCode ? (
+
+ ) : (
+
+ )}
+ {hasQrCode
+ ? translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.1e64659126',
+ 'Regenerate'
+ )
+ : translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.c541f67790',
+ 'Generate QR Code'
+ )}
+
+
+
+
+
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9',
+ 'Connect outside your Wi-Fi with a tailnet'
+ )}
+
+
+
+ {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.'
+ )}
+
+
+
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb',
+ 'Install'
+ )}{' '}
+ 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'
+ )}
+
+ {' '}
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a',
+ 'on your computer and phone.'
+ )}
+
+
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a',
+ 'Sign in to the same tailnet on both devices.'
+ )}
+
+
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5',
+ 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.'
+ )}
+
+
+ {translate(
+ 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e',
+ 'Regenerate the QR code and scan it from the Orca mobile app.'
+ )}
+
+
+
+
+
+
+ )
+ }
+ ```
+
+ Notes for the implementer:
+ - `Command shouldFilter={false}` because we already filter in `buildComboboxEntries`. Without this, cmdk's default fuzzy filter would re-filter the manually-injected `__use__` row out of view.
+ - The `value` prop on each `CommandItem` keeps cmdk happy but does not affect our filtering; we own filtering.
+ - `useMemo` is used for `selectedIface`, `entries`, and `queryParse` so re-renders triggered by parent prop changes don't churn the combobox state.
+ - If `isCustomLabel` is 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**
+
+ ```bash
+ pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
+ ```
+ Expected: all six tests pass.
+
+ If any fail:
+ - `toHaveTextContent` mismatches → check the trigger label format.
+ - `getByRole('option', { name: /Use "…"/ })` not found → verify `Command shouldFilter={false}` is set.
+ - Inline error not appearing → verify `showInlineError` is computed against `queryParse.ok`.
+
+- [ ] **Step 7: Run the full test suite for the settings folder**
+
+ ```bash
+ pnpm vitest run src/renderer/src/components/settings/
+ ```
+ Expected: all green. Confirm nothing else broke (e.g. `MobilePairingQrSection.test.tsx` if it exists).
+
+- [ ] **Step 8: Lint, typecheck, build**
+
+ ```bash
+ pnpm lint
+ pnpm typecheck
+ pnpm build
+ ```
+ Expected: all green. If `pnpm lint` complains about an unused import (e.g. `isCustomLabel` or `CommandSeparator`), remove it and re-run.
+
+- [ ] **Step 9: Commit**
+
+ ```bash
+ 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**
+
+ ```bash
+ pnpm lint
+ pnpm typecheck
+ pnpm test
+ pnpm build
+ ```
+ Expected: all green. This is exactly what `.github/workflows/` runs.
+
+- [ ] **Step 2: Manual smoke check**
+
+ If Orca can be launched locally with the desktop build:
+ 1. Open Settings → Mobile.
+ 2. Click the Network Interface dropdown — confirm both interfaces list.
+ 3. Type a tailnet address — confirm the `Use "…"` row appears.
+ 4. Click `Use "…"` — confirm the trigger switches to `… (custom)`.
+ 5. Click Refresh — confirm the custom selection survives.
+ 6. Type an invalid string — confirm the inline error appears and no `Use …` row is shown.
+ 7. Type an address that already matches an interface (e.g. `192.168.1.24`) — confirm the `Use` row 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.md` and `AGENTS.md` forbid committing PR evidence images). Use gstack browse if running the production binary:
+ ```bash
+ $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**
+
+ ```bash
+ git push origin HEAD
+ gh pr create \
+ --title "feat(mobile-pairing): combobox with manual network address entry" \
+ --body-file /tmp/pr-body.md
+ ```
+
+ The 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 `pnpm` boxes; note that `pnpm test` includes 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 `useMemo` over the interface list), UI quality (follows STYLEGUIDE.md; uses `text-statusRed` for the error), security (no new IPC; the address flows through the existing `selectedAddress` prop which `MobilePairingQrSection` already 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**
+
+ ```bash
+ gh pr edit --add-reviewer
+ ```
+ Or comment `@` in the PR if reviewer auto-assignment isn't available.
+
+---
+
+## Definition of done
+
+- All four `pnpm` checks 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.
diff --git a/package.json b/package.json
index 3b16182c1..7e0b8470f 100644
--- a/package.json
+++ b/package.json
@@ -121,6 +121,9 @@
"@stablyai/playwright-test": "^2.1.14",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-virtual": "^3.13.24",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@tiptap/extension-code-block-lowlight": "^3.22.5",
"@tiptap/extension-details": "^3.22.5",
"@tiptap/extension-image": "^3.22.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 989ae79cd..01f6ab4d1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -110,6 +110,15 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.13.24
version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@tiptap/extension-code-block-lowlight':
specifier: ^3.22.5
version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0)
@@ -375,6 +384,9 @@ packages:
7zip-bin@5.2.0:
resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==}
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
@@ -2597,6 +2609,35 @@ packages:
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@tiptap/core@3.22.5':
resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==}
peerDependencies:
@@ -2824,6 +2865,9 @@ packages:
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -3229,6 +3273,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
@@ -3250,6 +3298,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
@@ -3601,6 +3652,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -3875,6 +3929,12 @@ packages:
os: [darwin]
hasBin: true
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
@@ -4497,6 +4557,10 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
@@ -4868,6 +4932,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -5100,6 +5168,10 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -5472,6 +5544,10 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@@ -5618,6 +5694,9 @@ packages:
typescript:
optional: true
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@@ -5670,6 +5749,10 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
@@ -6041,6 +6124,10 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -6571,6 +6658,8 @@ snapshots:
7zip-bin@5.2.0: {}
+ '@adobe/css-tools@4.5.0': {}
+
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.6.0
@@ -8548,6 +8637,40 @@ snapshots:
'@tanstack/virtual-core@3.14.0': {}
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.0
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@tiptap/core@3.22.5(@tiptap/pm@3.22.5)':
dependencies:
'@tiptap/pm': 3.22.5
@@ -8788,6 +8911,8 @@ snapshots:
minimatch: 10.2.5
path-browserify: 1.0.1
+ '@types/aria-query@5.0.4': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.3
@@ -9227,6 +9352,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ ansi-styles@5.2.0: {}
+
ansi-styles@6.2.3: {}
app-builder-bin@5.0.0-alpha.12: {}
@@ -9280,6 +9407,10 @@ snapshots:
dependencies:
tslib: 2.8.1
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
@@ -9617,6 +9748,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -9907,6 +10040,10 @@ snapshots:
verror: 1.10.1
optional: true
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -10742,6 +10879,8 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
+ indent-string@4.0.0: {}
+
inflight@1.0.6:
dependencies:
once: 1.4.0
@@ -11024,6 +11163,8 @@ snapshots:
dependencies:
react: 19.2.5
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -11491,6 +11632,8 @@ snapshots:
mimic-response@3.1.0: {}
+ min-indent@1.0.1: {}
+
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@@ -11895,6 +12038,12 @@ snapshots:
powershell-utils@0.1.0: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
@@ -12116,6 +12265,8 @@ snapshots:
react-dom: 19.2.5(react@19.2.5)
typescript: 5.9.3
+ react-is@17.0.2: {}
+
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@types/hast': 3.0.4
@@ -12179,6 +12330,11 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
rehype-highlight@7.0.2:
dependencies:
'@types/hast': 3.0.4
@@ -12659,6 +12815,10 @@ snapshots:
strip-final-newline@4.0.0: {}
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
diff --git a/src/main/wsl-unc-delete.test.ts b/src/main/wsl-unc-delete.test.ts
index fe63f687b..932924b76 100644
--- a/src/main/wsl-unc-delete.test.ts
+++ b/src/main/wsl-unc-delete.test.ts
@@ -52,15 +52,7 @@ describe('tryDeleteWslUncPath', () => {
expect(execFileMock).toHaveBeenCalledTimes(1)
const [binary, spawnArgs] = execFileMock.mock.calls[0]
expect(binary).toBe('wsl.exe')
- expect(spawnArgs).toEqual([
- '-d',
- 'Ubuntu',
- '--',
- 'rm',
- '-f',
- '--',
- '/home/me/repo/file.txt'
- ])
+ expect(spawnArgs).toEqual(['-d', 'Ubuntu', '--', 'rm', '-f', '--', '/home/me/repo/file.txt'])
})
it('passes -rf for a recursive directory delete', async () => {
diff --git a/src/renderer/src/components/mobile/CustomNetworkAddressDialog.tsx b/src/renderer/src/components/mobile/CustomNetworkAddressDialog.tsx
new file mode 100644
index 000000000..c2b43ab41
--- /dev/null
+++ b/src/renderer/src/components/mobile/CustomNetworkAddressDialog.tsx
@@ -0,0 +1,113 @@
+import React, { useEffect, useState } from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { translate } from '@/i18n/i18n'
+import { parseManualNetworkAddress } from '../../../../shared/network/manual-address'
+
+type CustomNetworkAddressDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ // Why: prefill from the current selection when it is already a custom value
+ // so reopening to edit shows what is in use rather than a blank field.
+ initialValue?: string
+ onConfirm: (address: string) => void
+}
+
+export function CustomNetworkAddressDialog({
+ open,
+ onOpenChange,
+ initialValue,
+ onConfirm
+}: CustomNetworkAddressDialogProps): React.JSX.Element {
+ const [value, setValue] = useState(initialValue ?? '')
+
+ // Why: reseed each time the dialog opens so a prior cancelled edit doesn't
+ // leak into the next open.
+ useEffect(() => {
+ if (open) {
+ setValue(initialValue ?? '')
+ }
+ }, [open, initialValue])
+
+ const parsed = parseManualNetworkAddress(value)
+ // Why: only flag invalid input once the user has typed something — an empty
+ // field on open shouldn't read as an error.
+ const showError = value.trim() !== '' && !parsed.ok
+
+ const submit = (): void => {
+ if (!parsed.ok) {
+ return
+ }
+ onConfirm(parsed.address)
+ onOpenChange(false)
+ }
+
+ return (
+
+
+
+
+ {translate(
+ 'auto.components.mobile.CustomNetworkAddressDialog.title',
+ 'Custom network address'
+ )}
+
+
+ {translate(
+ 'auto.components.mobile.CustomNetworkAddressDialog.description',
+ 'Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.'
+ )}
+
+
+
+
+ {translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address')}
+
+
setValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ submit()
+ }
+ }}
+ />
+ {/* Why: neutral helper copy that doubles as validation guidance —
+ kept muted (not destructive-red) so a half-typed address doesn't
+ feel like a hard error. */}
+
+ {translate(
+ 'auto.components.mobile.CustomNetworkAddressDialog.hint',
+ 'Enter an IP address or a Tailscale hostname (ends in .ts.net).'
+ )}
+
+
+
+ onOpenChange(false)}>
+ {translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel')}
+
+
+ {translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address')}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx
index 2690f2155..af22a1dff 100644
--- a/src/renderer/src/components/mobile/MobileHero.tsx
+++ b/src/renderer/src/components/mobile/MobileHero.tsx
@@ -1,8 +1,8 @@
import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons'
+import { NetworkInterfacePicker } from './NetworkInterfacePicker'
import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy'
export { HeroIntro } from './MobileHeroIntro'
export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices'
@@ -197,34 +197,16 @@ export function HeroFlow({
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
-
-
-
-
-
- {networkInterfaces.map((iface) => (
-
- {iface.address} ({iface.name})
-
- ))}
-
-
+
([])
const [selectedAddress, setSelectedAddress] = useState(undefined)
+ // Why: tracks whether `selectedAddress` came from the user typing a
+ // manual value rather than from an OS-enumerated interface, so the
+ // refresh path can keep their choice instead of snapping back to LAN.
+ const [addressIsManual, setAddressIsManual] = useState(false)
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
const [devices, setDevices] = useState([])
const [revokingDeviceIds, setRevokingDeviceIds] = useState([])
@@ -215,9 +219,22 @@ export default function MobilePage(): React.JSX.Element {
// Resolve the new address before committing it so we can detect a real
// change and remint the QR — otherwise the QR keeps encoding the stale
// endpoint after a network refresh swaps the active interface.
- const newAddress = selectRefreshedNetworkAddress(selectedAddress, result.interfaces)
+ const newAddress = selectRefreshedNetworkAddress(
+ selectedAddress,
+ result.interfaces,
+ addressIsManual
+ )
if (mountedRef.current) {
+ // Why: selectRefreshedNetworkAddress can rewrite selectedAddress
+ // (e.g. when a refresh surfaces a tailnet interface and the user
+ // had been on LAN). Re-derive `addressIsManual` from the new
+ // value so the next refresh doesn't snap the user back to LAN
+ // just because they once picked a non-tailnet interface.
setSelectedAddress(newAddress)
+ const nextIsManual =
+ newAddress !== undefined &&
+ !result.interfaces.some((iface) => iface.address === newAddress)
+ setAddressIsManual(nextIsManual)
}
if (newAddress !== selectedAddress && hasGeneratedRef.current && mountedRef.current) {
void generatePairing(true, newAddress)
@@ -229,7 +246,7 @@ export default function MobilePage(): React.JSX.Element {
setRefreshingNetworkInterfaces(false)
}
}
- }, [selectedAddress, generatePairing, mountedRef])
+ }, [selectedAddress, generatePairing, mountedRef, addressIsManual])
useEffect(() => {
if (stage !== 'flow') {
@@ -241,10 +258,15 @@ export default function MobilePage(): React.JSX.Element {
const handleAddressChange = useCallback(
(address: string) => {
setSelectedAddress(address)
+ // Why: if the picked address is not in the OS-enumerated list, it is
+ // a user-typed manual entry — remember that so the next refresh does
+ // not snap it back to a tailnet/LAN fallback.
+ const isManual = !networkInterfaces.some((iface) => iface.address === address)
+ setAddressIsManual(isManual)
// Switching network must remint so the QR encodes the new endpoint.
void generatePairing(true, address)
},
- [generatePairing]
+ [generatePairing, networkInterfaces]
)
const copyPairingCode = useCallback(async () => {
diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx
new file mode 100644
index 000000000..b76c11294
--- /dev/null
+++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx
@@ -0,0 +1,119 @@
+import React, { useState } from 'react'
+import { Plus } from 'lucide-react'
+import { translate } from '@/i18n/i18n'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue
+} from '../ui/select'
+import { CustomNetworkAddressDialog } from './CustomNetworkAddressDialog'
+import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
+
+// Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection
+// (Settings → Mobile → Network Interface section) both need the same network
+// selector. Discovered interfaces come from the OS; the "Add custom address…"
+// footer opens a dialog for a Tailscale hostname or static IP the OS didn't
+// surface — the only way to pair across networks. Shared here so both surfaces
+// pick up the behavior automatically.
+
+// Why: a sentinel Select value for the footer action. It is never committed as
+// a real address; selecting it opens the custom-address dialog instead.
+const ADD_CUSTOM_VALUE = '__add_custom_address__'
+
+export type NetworkInterfacePickerProps = {
+ networkInterfaces: readonly MobileNetworkInterface[]
+ selectedAddress: string | undefined
+ onSelectedAddressChange: (address: string) => void
+ disabled?: boolean
+ className?: string
+ id?: string
+}
+
+function formatInterfaceLabel(iface: MobileNetworkInterface): string {
+ return `${iface.address} (${iface.name})`
+}
+
+export function NetworkInterfacePicker({
+ networkInterfaces,
+ selectedAddress,
+ onSelectedAddressChange,
+ disabled = false,
+ className,
+ id
+}: NetworkInterfacePickerProps): React.JSX.Element {
+ const [dialogOpen, setDialogOpen] = useState(false)
+
+ // Why: a selected address that isn't an OS-enumerated interface is a custom
+ // entry. Render it as a "(custom)" option so the trigger can display it —
+ // Radix Select only shows values that have a matching item.
+ const isCustomSelection =
+ selectedAddress !== undefined &&
+ !networkInterfaces.some((iface) => iface.address === selectedAddress)
+
+ const handleValueChange = (value: string): void => {
+ if (value === ADD_CUSTOM_VALUE) {
+ setDialogOpen(true)
+ return
+ }
+ onSelectedAddressChange(value)
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {networkInterfaces.map((iface) => (
+
+ {formatInterfaceLabel(iface)}
+
+ ))}
+ {isCustomSelection ? (
+
+ {translate(
+ 'auto.components.mobile.NetworkInterfacePicker.custom-option',
+ '{{address}} (custom)',
+ { address: selectedAddress }
+ )}
+
+ ) : null}
+ {networkInterfaces.length > 0 || isCustomSelection ? : null}
+
+
+ {translate(
+ 'auto.components.mobile.NetworkInterfacePicker.add-custom',
+ 'Add custom address…'
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
index 0c68f7651..47d02ae59 100644
--- a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
+++ b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx
@@ -1,76 +1,98 @@
-import { describe, expect, it, vi } from 'vitest'
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+
+import React from 'react'
+import { afterEach, describe, it, expect, vi } from 'vitest'
+import { cleanup, 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'
+import { TooltipProvider } from '../ui/tooltip'
-type ReactElementLike = {
- type: unknown
- props: Record
-}
+// Why: Radix Select/Dialog portal their content to and don't always
+// unmount synchronously when the test container tears down, so content can
+// leak between tests. Forcing cleanup restores the empty DOM before each render.
+afterEach(() => {
+ cleanup()
+})
-function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
- if (node == null || typeof node === 'string' || typeof node === 'number') {
- return
- }
- if (Array.isArray(node)) {
- node.forEach((entry) => visit(entry, cb))
- return
- }
- const element = node as ReactElementLike
- cb(element)
- if (element.props?.children) {
- visit(element.props.children, cb)
- }
-}
+const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' }
+const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' }
-function collectText(node: unknown): string {
- if (node == null || typeof node === 'boolean') {
- return ''
+function renderSection(
+ overrides: Partial> = {}
+) {
+ const onSelectedAddressChange = vi.fn()
+ const onRefreshNetworkInterfaces = vi.fn()
+ const onGenerateQr = vi.fn()
+ const props: React.ComponentProps = {
+ networkInterfaces: [LAN, TAILNET],
+ selectedAddress: TAILNET.address,
+ onSelectedAddressChange,
+ refreshingNetworkInterfaces: false,
+ onRefreshNetworkInterfaces,
+ loading: false,
+ hasQrCode: false,
+ onGenerateQr,
+ ...overrides
}
- if (typeof node === 'string' || typeof node === 'number') {
- return String(node)
- }
- if (Array.isArray(node)) {
- return node.map(collectText).join('')
- }
- const element = node as ReactElementLike
- return collectText(element.props?.children)
-}
-
-function findByAriaLabel(node: unknown, ariaLabel: string): ReactElementLike {
- let found: ReactElementLike | null = null
- visit(node, (entry) => {
- if (entry.props['aria-label'] === ariaLabel) {
- found = entry
- }
- })
- if (!found) {
- throw new Error(`element not found: ${ariaLabel}`)
- }
- return found
+ const user = userEvent.setup()
+ const utils = render(
+
+
+
+ )
+ return { ...utils, user, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerateQr }
}
describe('MobileNetworkInterfaceSection', () => {
- it('shows refreshed tailnet interfaces and wires the refresh action', () => {
- const onRefreshNetworkInterfaces = vi.fn()
- const tree = MobileNetworkInterfaceSection({
- networkInterfaces: [
- { name: 'en0', address: '192.168.1.24' },
- { name: 'tailscale0', address: '100.64.1.20' }
- ],
- selectedAddress: '192.168.1.24',
- onSelectedAddressChange: vi.fn(),
- refreshingNetworkInterfaces: false,
- onRefreshNetworkInterfaces,
- loading: false,
- hasQrCode: false,
- onGenerateQr: vi.fn()
- })
+ it('renders the trigger with the currently selected address', () => {
+ renderSection()
+ expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)')
+ })
- expect(collectText(tree)).toContain('100.64.1.20 (tailscale0)')
+ it('renders the (custom) label on the trigger when the selection is a manual address', () => {
+ renderSection({ selectedAddress: 'my-mac.tail-abcd.ts.net' })
+ expect(screen.getByRole('combobox')).toHaveTextContent('my-mac.tail-abcd.ts.net (custom)')
+ })
- const refreshButton = findByAriaLabel(tree, 'Refresh network interfaces')
- const onClick = refreshButton.props.onClick as () => void
- onClick()
+ it('commits an OS interface picked from the list', async () => {
+ const { user, onSelectedAddressChange } = renderSection()
+ await user.click(screen.getByRole('combobox'))
+ await user.click(screen.getByRole('option', { name: '192.168.1.24 (en0)' }))
+ expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24')
+ })
- expect(onRefreshNetworkInterfaces).toHaveBeenCalledTimes(1)
+ it('opens the custom-address dialog from the Add custom address row', async () => {
+ const { user } = renderSection()
+ await user.click(screen.getByRole('combobox'))
+ await user.click(screen.getByRole('option', { name: /add custom address/i }))
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByLabelText('Address')).toBeInTheDocument()
+ })
+
+ it('confirms a valid custom address typed into the dialog', async () => {
+ const { user, onSelectedAddressChange } = renderSection()
+ await user.click(screen.getByRole('combobox'))
+ await user.click(screen.getByRole('option', { name: /add custom address/i }))
+ await user.type(screen.getByLabelText('Address'), 'my-mac.tail-abcd.ts.net')
+ await user.click(screen.getByRole('button', { name: /use address/i }))
+ expect(onSelectedAddressChange).toHaveBeenCalledWith('my-mac.tail-abcd.ts.net')
+ })
+
+ it('disables the confirm button while the typed address is invalid', async () => {
+ const { user, onSelectedAddressChange } = renderSection()
+ await user.click(screen.getByRole('combobox'))
+ await user.click(screen.getByRole('option', { name: /add custom address/i }))
+ await user.type(screen.getByLabelText('Address'), 'not an address')
+ expect(screen.getByRole('button', { name: /use address/i })).toBeDisabled()
+ await user.click(screen.getByRole('button', { name: /use address/i }))
+ expect(onSelectedAddressChange).not.toHaveBeenCalled()
+ })
+
+ it('shows No interfaces found when the list is empty', () => {
+ renderSection({ networkInterfaces: [], selectedAddress: undefined })
+ expect(screen.getByRole('combobox')).toHaveTextContent(/no interfaces found/i)
})
})
diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx
index dff685415..4f17ffb24 100644
--- a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx
+++ b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx
@@ -1,10 +1,11 @@
+import React from 'react'
import { ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
import { Button } from '../ui/button'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
-import type { MobileNetworkInterface } from './mobile-network-interface-selection'
import { translate } from '@/i18n/i18n'
+import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker'
+import type { MobileNetworkInterface } from './mobile-network-interface-selection'
const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download'
@@ -19,10 +20,6 @@ type MobileNetworkInterfaceSectionProps = {
onGenerateQr: () => void
}
-function formatInterfaceLabel(iface: MobileNetworkInterface): string {
- return `${iface.address} (${iface.name})`
-}
-
export function MobileNetworkInterfaceSection({
networkInterfaces,
selectedAddress,
@@ -52,25 +49,12 @@ export function MobileNetworkInterfaceSection({
-
-
-
-
-
- {networkInterfaces.map((iface) => (
-
- {formatInterfaceLabel(iface)}
-
- ))}
-
-
- {/* Why: VPN/tailnet interfaces can appear after this pane mounts.
- Re-enumerating OS state here avoids requiring an Orca restart. */}
+
{
+ // Why: regression for the manual-address branch the PR adds — a
+ // transient empty refresh must not clobber the user's typed address.
+ it('keeps a manual address when refresh returns no interfaces', () => {
+ expect(selectRefreshedNetworkAddress('my-mac.ts.net', [], true)).toBe('my-mac.ts.net')
+ })
+
+ // 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)
})
diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts
index 122d26851..2edd817b6 100644
--- a/src/renderer/src/components/settings/mobile-network-interface-selection.ts
+++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts
@@ -7,12 +7,22 @@ export type MobileNetworkInterface = {
export function selectRefreshedNetworkAddress(
currentAddress: string | undefined,
- interfaces: readonly MobileNetworkInterface[]
+ interfaces: readonly MobileNetworkInterface[],
+ // Why: callers that explicitly know the user picked a manual address
+ // (not an OS-enumerated one) pass this so the refresh path keeps their
+ // selection instead of snapping back to a tailnet/LAN fallback.
+ currentAddressIsManual: boolean = false
): string | undefined {
+ // Why: an empty refresh result usually means discovery is transiently
+ // unavailable, not that the user wants to drop their selection. Keep the
+ // manual address so a recovering discovery doesn't clobber it.
if (interfaces.length === 0) {
- return undefined
+ return currentAddressIsManual ? currentAddress : undefined
}
- if (currentAddress && interfaces.some((iface) => iface.address === currentAddress)) {
+ if (
+ currentAddress &&
+ (currentAddressIsManual || interfaces.some((iface) => iface.address === currentAddress))
+ ) {
return currentAddress
}
return (
diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx
index 1b0d33103..cbc9c0cf1 100644
--- a/src/renderer/src/components/ui/command.tsx
+++ b/src/renderer/src/components/ui/command.tsx
@@ -99,7 +99,10 @@ function CommandInput({
}) {
return (
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 690c3285b..ea1d52e94 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -9994,6 +9994,20 @@
"cefd048225": "Back",
"c5ad56786d": "spinner"
}
+ },
+ "CustomNetworkAddressDialog": {
+ "title": "Custom network address",
+ "description": "Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.",
+ "label": "Address",
+ "placeholder": "my-mac.ts.net or 192.168.1.50",
+ "hint": "Enter an IP address or a Tailscale hostname (ends in .ts.net).",
+ "cancel": "Cancel",
+ "use": "Use address"
+ },
+ "NetworkInterfacePicker": {
+ "trigger-label": "Network address to advertise",
+ "custom-option": "{{address}} (custom)",
+ "add-custom": "Add custom address…"
}
},
"gitlab": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index cd7e9b6f0..eb935e9ca 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -9994,6 +9994,20 @@
"cefd048225": "Atrás",
"c5ad56786d": "hilandero"
}
+ },
+ "CustomNetworkAddressDialog": {
+ "title": "Dirección de red personalizada",
+ "description": "Anuncia una dirección que tu teléfono pueda alcanzar cuando no esté en la misma red Wi-Fi: por ejemplo, un nombre de host de Tailscale o una IP estática.",
+ "label": "Dirección",
+ "placeholder": "my-mac.ts.net o 192.168.1.50",
+ "hint": "Introduce una dirección IP o un nombre de host de Tailscale (termina en .ts.net).",
+ "cancel": "Cancelar",
+ "use": "Usar dirección"
+ },
+ "NetworkInterfacePicker": {
+ "trigger-label": "Dirección de red para anunciar",
+ "custom-option": "{{address}} (personalizada)",
+ "add-custom": "Añadir dirección personalizada…"
}
},
"gitlab": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index ff5e28583..94240fb5a 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -9994,6 +9994,20 @@
"cefd048225": "戻る",
"c5ad56786d": "スピナー"
}
+ },
+ "CustomNetworkAddressDialog": {
+ "title": "カスタムネットワークアドレス",
+ "description": "同じ Wi-Fi に接続していないときにスマートフォンから到達できるアドレスを指定します。たとえば Tailscale のホスト名や静的 IP です。",
+ "label": "アドレス",
+ "placeholder": "my-mac.ts.net または 192.168.1.50",
+ "hint": "IP アドレスまたは Tailscale のホスト名(.ts.net で終わる)を入力してください。",
+ "cancel": "キャンセル",
+ "use": "アドレスを使用"
+ },
+ "NetworkInterfacePicker": {
+ "trigger-label": "通知するネットワークアドレス",
+ "custom-option": "{{address}}(カスタム)",
+ "add-custom": "カスタムアドレスを追加…"
}
},
"gitlab": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index df826365e..36d207883 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -9994,6 +9994,20 @@
"cefd048225": "뒤로",
"c5ad56786d": "스피너"
}
+ },
+ "CustomNetworkAddressDialog": {
+ "title": "사용자 지정 네트워크 주소",
+ "description": "같은 Wi-Fi에 있지 않을 때 휴대폰이 연결할 수 있는 주소를 알립니다. 예: Tailscale 호스트 이름 또는 고정 IP.",
+ "label": "주소",
+ "placeholder": "my-mac.ts.net 또는 192.168.1.50",
+ "hint": "IP 주소 또는 Tailscale 호스트 이름(.ts.net으로 끝남)을 입력하세요.",
+ "cancel": "취소",
+ "use": "주소 사용"
+ },
+ "NetworkInterfacePicker": {
+ "trigger-label": "알릴 네트워크 주소",
+ "custom-option": "{{address}} (사용자 지정)",
+ "add-custom": "사용자 지정 주소 추가…"
}
},
"gitlab": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 3f0dd9bf6..cc77560af 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -9994,6 +9994,20 @@
"cefd048225": "返回",
"c5ad56786d": "旋转器"
}
+ },
+ "CustomNetworkAddressDialog": {
+ "title": "自定义网络地址",
+ "description": "指定当手机不在同一 Wi-Fi 时可以访问的地址,例如 Tailscale 主机名或静态 IP。",
+ "label": "地址",
+ "placeholder": "my-mac.ts.net 或 192.168.1.50",
+ "hint": "请输入 IP 地址或 Tailscale 主机名(以 .ts.net 结尾)。",
+ "cancel": "取消",
+ "use": "使用地址"
+ },
+ "NetworkInterfacePicker": {
+ "trigger-label": "要公布的网络地址",
+ "custom-option": "{{address}}(自定义)",
+ "add-custom": "添加自定义地址…"
}
},
"gitlab": {
diff --git a/src/shared/network/manual-address.test.ts b/src/shared/network/manual-address.test.ts
new file mode 100644
index 000000000..3d1b48b43
--- /dev/null
+++ b/src/shared/network/manual-address.test.ts
@@ -0,0 +1,81 @@
+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'
+ })
+ })
+ })
+})
diff --git a/src/shared/network/manual-address.ts b/src/shared/network/manual-address.ts
new file mode 100644
index 000000000..9f6a8421f
--- /dev/null
+++ b/src/shared/network/manual-address.ts
@@ -0,0 +1,36 @@
+// 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 }
+}