Repository navigation
Inbox mobile: the iPhone app, pair, list, thread, picks, Send (M1) - #1778
Merged
Merged
Conversation
backnotprop
force-pushed
the
feat/mobile-m1-app
branch
4 times, most recently
from
October 8, 2026 07:58
2f0e2a5 to
7f714b7
Compare
apps/ios: a SwiftUI app (ai.plannotator.app, iOS 26) with a local Swift package, PlannotatorKit, that talks to the Inbox through the device door (adr/implementation/inbox-mobile.md). Pairing by typed address and six digits or the QR link, the list as inset sections with the N new pill, threads with native question cards, one-tap picks, the docked reply bar, Send with a kept idempotency key, Resolve, Delete, Settings and the tailnet row. Colours generated from plannotator.css with a drift check. The XCUITest proof runs against a compiled Inbox through apps/ios/scripts/proof.ts, in one paths-gated macOS job (ios.yml).
Send saves what the cards' fields still hold (the drafts live in the session and Send commits them first). The list and the thread wrap at the accessibility text sizes. A Send whose answer is lost re-reads the thread: applied is shown as sent, an unknown outcome says so, and a retry keeps the key. The thread screen draws Can't reach (with Try Again) and This thread was deleted. The proof adds a step for each.
Agent markdown keeps only https and mailto links tappable, so a plannotator:// link in a message is plain text and never reaches the app's URL handler. A pair link opened from outside the app asks "Pair with <name> at <address>?" (the address it will contact) before it redeems; the in-app scanner is unchanged. A + in the link's query reads as a space. ios.yml cancels a stale run on a new push.
…of keeps crash reports
InboxAddress refuses user info (a@b would show a and dial b), a path, a query; hostPort is rebuilt from the parsed host and port. The outside-link confirmation reads "Pair with this computer?" with the dialled address on its own first line and the link's name after it, marked as a name. A scanned link's computer joins the known list only after it pairs.
backnotprop
force-pushed
the
feat/mobile-m1-app
branch
from
October 8, 2026 12:43
08711d5 to
975bb6c
Compare
InboxAddress reads percentEncodedHost and accepts only a plain host name, an IPv4 literal or a bracketed IPv6 literal (no %, no @). It keeps the host and port as fields, builds baseURL from them with URLComponents and writes hostPort from the same fields, so the shown and the dialled address are one value; no string is parsed a second time and nothing is force-unwrapped. Tests: %2540 refused, [fd7a::1]:8443 dialled with its brackets.
backnotprop
added a commit
that referenced
this pull request
Oct 8, 2026
# Conflicts: # AGENTS.md # bunfig.toml
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The story
This adds the Plannotator iPhone app at
apps/ios: a SwiftUI app that pairs with the Inbox on a computer, shows the list as inset sections, opens a thread with native question cards, saves a pick with one tap and sends the reply. It talks only to the device door inadr/implementation/inbox-mobile.md, through a small local Swift package,PlannotatorKit, with no third-party code. It is proved in action: an XCUITest flow drives the app in a simulator against a compiled Inbox while real agents write throughplannotator inbox mcp, and one paths-gated macOS job runs that flow in CI.Proved against main at
7bdb99532(P1c7bb5050d, its follow-up 17792f57787f1, P2 1780d7b3d064b, S1 1777d252b3a19, then 1783, all merged): the pairing routes, the device door and the tailnet listener. The final run used a binary compiled from this branch rebased on that commit, so its server code is main's. Earlier runs used PR 1776's headf56368bc4from a detached worktree, with the same result.Review fixes (REVIEW-PLANNOTATOR-MOBILE-M1-2026-10-08, four blockers, one push)
Send keeps words still in a field. The cards' Other, note and answer fields now live in the session (
SourceSession.drafts), not in view state. Send commits them as picks first, waits for the pick chain, then sends. A field also saves when it loses focus, as before. Proved: a note typed and still focused, then Send. The agent'swait_for_replybody has both the pick and the note (framesnote-before-send,note-after-send).Dynamic Type at the accessibility sizes. At those sizes:
Frames:
ax-list,ax-thread,ax-thread-card, ataccessibility-extra-extra-extra-large, light and dark.No false "Not sent". When a Send gets no answer, the phone reads the thread again:
InboxError.sendUnconfirmed). The key is kept, so a retry is applied once.Proved through a proxy that lets the Inbox apply the Send and then kills the connection. The card shows "Sent", no problem line appears, the agent gets the answer, and the thread holds exactly one reply from the person (frame
send-dropped-after-apply).The thread's unreachable and gone states.
Proved: the proxy down, a never-opened thread shows Can't reach, the proxy back up, Try Again loads it (frame
thread-unreachable). A thread deleted through the window route while open, then a pull, shows the gone state (framethread-deleted).The proof runner now puts a loopback proxy in front of the door. The test can switch it to
downordrop-reply. It also gained/person-replies,/delete-on-computerand/text-size. Also fixed while there: the Send problem line's VoiceOver label is now its words, not the symbol's name. The review's other items are follow-ups. The flow now takes 206 s locally. The branch is rebased on maind7b3d064b(P2), and the proof binary was compiled there. P2 changes nothing the app calls: digits still go to the tailnet or loopback, and the app does not try the LAN address yet.plannotator-ops's blocker: pairing links (one push)
httpsandmailtolinks tappable (inlineMarkdowninSupport.swift). Aplannotator://link, or a link in any other scheme, is drawn as plain text.onOpenURL) shows a confirmation with Cancel and Pair (its wording is in the next section). The address is the one that will be contacted: the link's tailnet address, the only path this build tries. Nothing is redeemed until Pair. The in-app scanner is unchanged.link-in-message).simctl openurlwith a live offer's link shows the confirmation (framepair-link-confirm). The device count stays 1 through Cancel, and Pair makes it 2.+fix. The confirmation showedMichael's+MacBook+Pro+(2), because a link written by URLSearchParams uses+for spaces.PairLinknow reads+as a space; a literal plus arrives as%2B. There is a package test for it.ios.ymlgains aconcurrencygroup, so a new push cancels the stale run.d252b3a19(S1), and the proof binary was compiled there. The flow now takes 242 s locally.plannotator-ops's delta review: the address the person reads
InboxAddressnow refuses user info, a path and a query. A link withtailnet=macbook-pro.tail0000.ts.net:8443@evil.examplewould have shown the first host and dialledevil.example; it now parses to no address.InboxAddressreads the rawpercentEncodedHost. It accepts only a plain host name (letters, digits, dots and hyphens; IPv4 literals included) or a bracketed IPv6 literal, and refuses%and@(%2540is refused). It keeps the host and port as fields, buildsbaseURLfrom them withURLComponents, and writeshostPortfrom the same fields. No string is parsed twice and nothing is force-unwrapped.[fd7a::1]:8443is accepted and dialled with its brackets. It is stored on disk as thehost:portstring, read back through the same checks. There are package tests for each case.host:porton its own, thenNamed “<link's name>”, then the warning. A name that reads like an address cannot push the real host out of view (framepair-link-confirm). A link whose address carries user info gets "no address your phone can reach" and no Pair button (framepair-link-userinfo).7f714b704and4dad7c9a6the CI simulator left the app right after the pairing cover opened. The pairing cover no longer touches VisionKit on the simulator (08711d570). The proof now copies any crash report into the CI files.7bdb99532, and the proof binary was compiled there. The flow now takes 253 s locally.What changed
apps/ios/project.yml: the project for XcodeGen.Plannotator.xcodeprojis generated from it and committed, so a fresh clone builds withxcodebuildalone. The bundle id isai.plannotator.app, the target is iOS 26 and iPhone only. There is an app target, a UI test target and a scheme that runs both the package tests and the UI tests.apps/ios/PlannotatorKit/(local Swift package):InboxClient.swift: the door's calls (pair, health, threads, a thread, seen, picks, reply, resolve, delete, the decision switch, revoke) and the errors in the Inbox's own words.InboxAddressuses plain HTTP for a loopback host and HTTPS for anything else, which is the tailnet publication.EventStream.swift:GET events?cursor=with its SSE framing.Models.swift: the wire shapes frompackages/core/inbox-types.ts.PairLink.swift: theplannotator://pairlink (section 1).Keychain.swift: the token and the pairing secret, kept on this device only (AfterFirstUnlockThisDeviceOnly).MessageBlocks.swift: the markdown splitter, cut at the question blocks.apps/ios/Plannotator/:App/AppModel.swift: the paired sources, pairing, and Remove this source.App/SourceSession.swift:Inbox/: 1.1, 2.1B, 2.2, 2.3 and 2.4.Thread/: 3.1 to 3.5. This covers the question card, the reply bar and composer, the native markdown blocks, and the row for blocks that are not drawn natively.Pairing/: 1.2 and 1.3.Settings/: 9.1 and 9.2 (the tailnet row only).Colors.xcassets: generated.Assets.xcassets: the app icon, the Tater frame and the agent marks.apps/ios/scripts/gen-colors.ts: writesColors.xcassetsfrompackages/ui/themes/plannotator.css(oklch to sRGB, light and dark).--checkfails on drift.apps/ios/scripts/proof.ts: the proof runner (see Proofs).apps/ios/PlannotatorUITests/ProofTests.swift: the flow.apps/ios/README.md..github/workflows/ios.yml: one job (shape below).bunfig.toml:pathIgnorePatternsgainsapps/ios/**.AGENTS.md: Project Structure has anapps/iosentry, and the Inbox section's "On a phone" line (as P1 left it) now lists the app as built, with its proof.Record sections this makes real
Renders 1.1, 1.2, 1.3, 2.1B, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3A, 3.4A, 3.5 and 9.1, plus 9.2 with the tailnet row. Side-by-side sheets (record light, built light, record dark, built dark) for each one are in
research/operational/mobile-proofs/M1-2026-10-08/compare/. The raw screenshots are inbuilt/. The recording of pair, pick and send isM1-pair-pick-send.mp4. Contract sections 1 and 2 are covered: the link, the redemption, the door routes the app calls, the error codes, and section 6's keys for Send.Proofs
XCUITest against the compiled Inbox:
bun apps/ios/scripts/proof.ts --binary <plannotator compiled at c7bb5050d>. The runner startsinbox --backgroundunder a temp data dir withPLANNOTATOR_BROWSER=none, connects six agents throughscripts/inbox-sim.ts, creates a fresh simulator (iPhone 17, iOS 26.5) and runsxcodebuild test. The test calls a loopback control server for the things only the computer can do. Each step below is asserted:wait_for_replyreturns a body that contains the pick, the note, the Other text and the words.device_revoked, and the app draws "This phone was removed" with Pair Again.GET /api/inbox/devicescomes back empty.Result:
** TEST SUCCEEDED **, 135 s for the flow, plus 8 package tests.Package tests (critical pure logic only):
:::questioninside a fence stays code.vasks for an update, and loopback uses plain HTTP.Colours:
bun apps/ios/scripts/gen-colors.ts --check.Still needs the iPhone 15 (no Apple ID in Xcode yet): camera pairing with VisionKit's scanner (the simulator has no camera and draws "The camera isn't available"), the torch, the feel of haptics, and a phone reaching the Inbox over a real tailnet (P1 files the tailnet run).
CI job shape (
.github/workflows/ios.yml)ios-app, onmacos-latest(the macOS imagerelease.yml's test matrix uses). There is no matrix and one simulator destination (iPhone 17 on the newest iOS runtime). Timeout is 45 minutes.apps/ios/**,packages/server/inbox*,packages/shared/inbox/**,packages/core/inbox*,packages/ui/themes/plannotator.css,apps/hook/server/index.ts,apps/hook/server/inbox*,scripts/inbox-sim.ts, and the workflow file itself.bun install --frozen-lockfile./Applications/Xcode_26*.app. The job fails plainly if the image has none.~/Library/Caches/org.swift.swiftpmand~/ios-derived(DerivedData). The key is a hash of the Swift sources, the pbxproj and Package.swift.bun apps/ios/scripts/proof.ts --binary .local/plannotator --device "iPhone 17" --derived ~/ios-derived --shots .local/proof/ios..local/proof/ios/as theios-proofartifact.2f0e2a56e, 21 minutes. One run before it failed at its first tap because the shared runner took over a minute to start the app; the test's waits are now 30 s. First green run: 37733503673, 17.6 minutes with a cold cache. The first run failed because the agent'swait_for_replyhit the MCP SDK's 60 s client timeout on the slower runner; the proof now calls again, as an agent would (second commit).Gates run
bun run typecheckpassed.bun run check:release-versionpassed (v0.28.8).apps/ioskeeps its own version line (MARKETING_VERSION1.0, build 1, inproject.yml) and stays outside the release-bumped files.bun test scriptsunder a temp data dir: 211 pass, 0 fail.gitleaks git . --log-opts=origin/main..HEAD: no leaks.spawnandspawnSyncand no exec, and no request data reaches a filesystem or network sink.xcodebuild buildhas no warnings from our code.What it leaves out
lanaddress is not tried yet; onlytailnetis.Deviations from the record or the contract, with the reason
Two tabs, not three. The Decisions tab would be empty until M3. An empty tab is a rough edge, so it comes with M3.
1.1 has no Workspaces row. Its subtitle reads "Connect the Inbox on your computer." A row that does nothing is worse than none. The row and the drawn words come back with M7.
The source switcher is the system's. It is
toolbarTitleMenuwithnavigationSubtitle. iOS 26 draws its chevron on the inline title (2.3) but not beside the large title, and the subtitle uses the system's style without the laptop glyph. This is native behaviour; a hand-built title would lose the system's collapse on scroll. With one computer the menu holds that computer and "Add a source"; with several, each one.1.3 lists only "On your tailnet". It shows the computers this phone knows plus a field for an address. "On this Wi-Fi" needs Bonjour and the LAN listener (P2). The footer is the record's words.
The decision switch saves at once. It saves
recordingthrough 7.21 and opens no card. The card sheet (5.1) is M3. Until then the drafted words apply, which is the Inbox's default.2.4's swipe buttons are iOS 26's own round buttons, not the record's flat panels. A full swipe resolves, as drawn.
The question card's small switch is the system switch drawn at 78% to match the record's small switch (
tgl.sm), so the row's words fit on one line.Colours come from
plannotator.csstokens as they are. The record's sheet used a few hand-tuned phone values (for example--fg2at oklch 0.47, where the theme's muted-foreground is 0.40), and the brief asks for generation from the theme. Three colours are not one token:Those choices are named in
gen-colors.ts.The record does not draw these states; this PR builds them:
The composer's draft follows the desktop ("Prompt: answer.") but drops the extra period when an answer already ends with one.
The app icon is a placeholder. It is the 256 px Plannotator mascot (
apps/vscode-extension/images/icon.png) scaled to 1024. A 1024 master is an owner item. The 1.1 Tater is the first frame of the original 5376x256 sheet, taken from Workspaces' history (853d293af^), mirrored, at 3x, because the 77x88 frame in the repo is too small for 84 pt.The screenshots show this Mac's real name, "Michael's MacBook Pro (2)". P1 reads
scutil --get ComputerName.There are no contract changes. The pick body sends
"answer": nullexplicitly to clear a pick.Limits
Dependencies
No third-party Swift or npm dependencies. XcodeGen (
brew install xcodegen) is needed only to regenerate the project after editingproject.yml.Not done, named
@ScaledMetric.Plannotator.xcodeprojmatchesproject.yml; that would install XcodeGen on every run.