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.' + )} +

+
+
+ + + + + + + + + + {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} + +
+ + + + {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.' + )} +

+
    +
  1. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb', + 'Install' + )}{' '} + {' '} + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a', + 'on your computer and phone.' + )} +
  2. +
  3. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a', + 'Sign in to the same tailnet on both devices.' + )} +
  4. +
  5. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5', + 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.' + )} +
  6. +
  7. + {translate( + 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e', + 'Regenerate the QR code and scan it from the Orca mobile app.' + )} +
  8. +
+
+
+
+
+ ) + } + ``` + + 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.' + )} + + +
+ + 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).' + )} +

+
+ + + + +
+
+ ) +} 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')} - +