Skip to content

Inbox mobile: decisions and New message in the iPhone app (M3) - #1791

Merged
backnotprop merged 5 commits into
mainfrom
feat/mobile-m3-decisions
Oct 8, 2026
Merged

backnotprop merged 5 commits into
mainfrom
feat/mobile-m3-decisions

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

The story

The iPhone app now records decisions and writes New messages. The decision switch on a question opens the decision card as a sheet, a Decisions tab lists each project's waiting questions and decisions, and New message on the reply bar writes to a live agent session, picks from a menu when several are live, and shows the desktop's "not running" words when none is. It is proved in action: the XCUITest flow drives the app against a compiled Inbox, and the live sessions are the Claude Code mod's own code on real processes, which take the New message as a turn and answer in the thread.

Rebased on main 3dedb6ca4 (R1, R2, PR 1793, PR 1794, PR 1796, M8 and M7 merged). It needs nothing from M2 (PR 1785); the overlap with M2 is listed below.

What changed

  • apps/ios/PlannotatorKit/Sources/PlannotatorKit/Decisions.swift (new): the wire shapes of exchanges 7.21 to 7.24 (InboxDecision, InboxWaitingDecision, InboxDecisionsModel, InboxLiveSession, InboxLiveSessions, InboxDecisionDraft), the client calls decisions(project:), keepDecision(message:key:draft:) (recording on with the card's words, draft: null to keep the drafted words), sessions(thread:) and newMessage(thread:session:body:idempotencyKey:), and DecisionWords, the Swift port of draftInboxDecisionText and draftInboxDecisionReason.
  • Models.swift: InboxQuestion.decisionDraft; a public InboxAuthor init. InboxClient.swift: a comment.
  • apps/ios/Plannotator/Decisions/DecisionSheet.swift (new): 5.1.
  • apps/ios/Plannotator/Decisions/DecisionsTab.swift (new): 5.2, the third tab.
  • apps/ios/Plannotator/Thread/NewMessage.swift (new): the New message button, the menu (8.1), the compose sheet (8.2), the not-running card (8.3).
  • Thread/QuestionCard.swift: the decision row. Turning the switch on opens the sheet, and recording starts at the sheet's Done. With the switch on, tapping the row's words opens the sheet again; VoiceOver gets the same as the action "Edit the decision". Turning it off saves at once.
  • Thread/ReplyBar.swift: one line, New message as the bar's leading button.
  • App/SourceSession.swift: keepDecision, newMessage, and the Decisions block (the tab's project, each project's model from the cache first, then the door). refresh() also reloads the shown project's decisions, so the tab follows the event stream like the list.
  • App/SourceStates.swift (new): the "removed" and "can't reach" screens, moved out of InboxList.swift so Inbox and Decisions share them. InboxTab.swift: the source subtitle moved onto SourceMenu, so both tabs share it. Support.swift: When.dayWords ("today", "yesterday", "Oct 3"). PlannotatorApp.swift: the Decisions tab.
  • PlannotatorUITests/DecisionsProofTests.swift (new), scripts/proof.ts (an M3 block; --only and the data-dir guard of row 5109, in the same words M2 uses; build once, warm up, then test), PlannotatorUITests/WarmUpLaunch.swift (new: the warm-up and ProofWait.opening, which M1's ProofTests opening waits read too), project.pbxproj (regenerated by xcodegen generate), README.md.
  • .github/workflows/ios.yml: the path gate gains apps/hook/hooks/mod/inbox* and apps/hook/hooks/mod/testing/**, because the proof now drives that code. plannotator-ops: this is the only change to the job's shape. The job still has one runner and one simulator.
  • AGENTS.md: the Inbox section's "On a phone" line.

Record sections this makes real

5.1, 5.2, 8.1, 8.2 and 8.3. Side-by-side sheets (record light, built light, record dark, built dark) are in research/operational/mobile-proofs/M3-2026-10-08/compare/, the raw frames in built/, and the recording is M3-decisions-new-message.mp4. Contract exchanges 7.21 to 7.24 are covered, plus section 6's key rule for New message.

  • 5.1: aligned. Medium detent, the X leading, the tinted check trailing, the diamond title, "Decision" drafted from the pick ("Retry with the same idempotency key."), "Why (optional)" as "Asked by Claude Code: ", and "In billing-svc, when you send".
  • 5.2: aligned. The large title with the source menu, the project pill, "What holds true in billing-svc, and what waits on a call.", Waiting, then Settled newest first with "From your answer to Claude Code · Recorded at Send" or "Recorded by Claude Code · record_decision" and the date, then "Replaced or retired" folded with a chevron.
  • 8.1: a system menu headed "Two Claude Code sessions are live in ledger". Each item reads "Started " with "Wrote this thread. Idle since …" or "Idle since …", the thread's writer first (.menuOrder(.fixed), because a menu that opens upward would otherwise reverse it). The record draws Pi; the proof can only make Claude Code sessions live (see Proofs).
  • 8.2: aligned. Full height, X, "New message", the tinted up arrow, the To row "Claude Code, started 7:19 AM" with "in api-gateway". The last part wraps to a second line when the chip is long.
  • 8.3: the desktop's words, the window's two variants (a connected host, or none), and Reply instead, which opens the reply composer.

Proofs

  • XCUITest against the compiled Inbox: bun apps/ios/scripts/proof.ts --binary <plannotator compiled from this branch> --device "iPhone 17" (--only PlannotatorUITests/DecisionsProofTests runs this test alone). The script makes its own simulator (plannotator-proof-<pid>, iPhone 17, iOS 26.5) and deletes it at the end. Before pairing it seeds:

    • three threads in billing-svc through plannotator inbox mcp;
    • three agent record_decisions, one of them retired through the window's route;
    • a Pi thread in search-indexer;
    • three live Claude Code sessions, ClaudeSession from apps/hook/hooks/mod/testing/claude-session.ts (the mod's own PlannotatorMod on a Host of real processes, HTTP and files, polling the Inbox's bridge from its project folder): two in ledger, of which one wrote the thread, and one in api-gateway.

    The test asserts, in order:

    1. A Yes picked and sent with the switch off records nothing: the window's GET /api/inbox/decisions has no decision from that thread.
    2. The switch turned on opens the sheet with the drafted words; Cancel leaves the switch off.
    3. On again, then Done: the row reads "Answering this records a decision" with the switch on.
    4. The Decisions tab shows the question under Waiting, and the row opens its thread. Send there.
    5. The tab shows "Retry with the same idempotency key." under Settled, first, "From your answer to Claude Code". The window's decisions route has exactly that one decision from the thread. "Replaced or retired" unfolds to the retired one. A settled row opens its thread.
    6. In api-gateway, New message opens the compose sheet to the one live session. Send closes it and the message shows in the thread "to Claude Code". That session's $.prompt.submit got the wake (submits: gateway 1, the other two 0). It answered with reply_to the wake's id, and the answer shows in the same thread. The thread's row is in the list.
    7. In ledger, New message opens the menu of two with the writer first. The other session is picked and sent to; only that session got the turn (ledger-other 1, ledger-writer 0).
    8. In search-indexer, "Pi is not running in search-indexer" with the desktop's words. Reply instead opens the reply composer.
    9. The test deletes its threads on the computer and Removes this source, so the M1 flow after it starts from a fresh app.

    Before step 9, step 8 also opens the not-running card at the largest text size and asserts Reply instead is hittable (review blocker 1).

    Cold runner: one warm-up before the suite, one opening wait for every class (review blocker 3). proof.ts builds once (build-for-testing), then runs PlannotatorUITests/WarmUpLaunch alone, then every other test (test-without-building). The warm-up launches the app on the fresh simulator, pairs once with the proof's Inbox (the first Keychain write, the first door requests, the first list drawn), Removes the source and quits, so every proof class starts warm from a fresh app. The warm-up pays a cost and proves nothing, so it never fails the proof. It gets two tries, and a try first removes a source a stopped try left. Run 37815033252 attempt 2 showed why: the warm-up itself stopped inside XCTest ("Timed out while evaluating UI query") after the first screen took 50 s and the pairing cover more than 120 s to first appear. Every class's opening waits read one constant, ProofWait.opening = 120 s (PlannotatorUITests/WarmUpLaunch.swift). The measurement that sets it comes from runs 37799098038 (both attempts) and 37811000174. On the cold macOS runner the app's first launch took 42 to 62 s to reach idle, and the first screens after it more than 30 s each. Run 37811000174's DecisionsProofTests failed right after its first pairing: "Failed to get matching snapshots: Unable to perform work on main run loop, process main thread busy for 30.0s". That is XCTest's own fixed 30 s snapshot limit while the app's first-time work ran on a starved runner. The warm-up now takes that first pairing, and the live Claude Code sessions start only before the New message steps (/m3-live), so the runner is quiet while the app pairs. That run's ProofTests failure at 43.8 s was not a wait. The app launched warm in 5 s, but the earlier test had failed before its Remove this source, so the app was still paired and the first-run screen ("connect-computer") never appeared. The bound was wrong for a cold first launch; the app was not slow. A local run takes a few seconds per screen.

    Frames are light and dark for every screen, plus the accessibility text size for 5.1, 5.2 and 8.3. The menu and the popover are shot dark by opening them while the simulator is dark, because switching appearance under an open popover leaves its glass light. Result on the rebased head 417ef692f (binary compiled from it): ** TEST SUCCEEDED **. The 12 package tests passed, DecisionsProofTests passed in 174 s, and M1's ProofTests passed in 254 s after it on the same simulator and Inbox. The log is proof-run.log in the proof folder.

  • Package tests: 12 in 3 suites, unchanged and passing.

  • Mod engine harness: CLAUDE_CONFIG_DIR=<temp> sh scripts/test-claude-code-mod.sh: 23 pass, 1 fail. The failure is register > a call goes to the Inbox's /mcp with the session's folder and real id; after /clear, the new id: it expects no _meta, and the mod sends _meta: { "ai.plannotator/inbox-wakes": true }. This PR changes nothing under apps/hook, so it fails the same way on main. It is a separate item for plannotator-ops, not fixed here.

  • Still needs the iPhone 15 (no Apple ID in Xcode yet): camera pairing (M1's item), the feel of the switch, the menu and the sheet under a finger, and the success haptic on delivery. Nothing in M3 is device-only otherwise.

Gates run

  • bun run typecheck: pass.
  • bun run check:release-version: pass (v0.28.8; apps/ios keeps its own version line).
  • bun test scripts under a temp data dir: 211 pass, 0 fail. No TypeScript under packages or apps/hook changed.
  • gitleaks git . --log-opts=origin/main..HEAD: no leaks.
  • Semgrep is not installed on this machine. The TypeScript change is proof.ts only: argv spawn and spawnSync, and no request data reaches a file or network sink beyond the loopback window routes.
  • xcodebuild build-for-testing: no warnings from our code.
  • The full XCUITest proof above: the package tests, DecisionsProofTests, and M1's ProofTests on the same run.

What it leaves out

  • Attachments (M2), guides (M4), notifications (M5), the relay (M6), Workspaces (M7).
  • Retire and Replace on the phone. They are not on the device door (contract section 2: the computer keeps them), so a row only opens its thread. A decision an agent recorded has no thread, so its row is not a button.
  • The "Settled: " line under a sent card. The window draws it; the iPhone record does not.
  • The review's seven follow-ups (research/reviews/REVIEW-PLANNOTATOR-MOBILE-M3-2026-10-08.md):
    1. New message pressed as a second session goes live, or before the first read, opens the sheet to the first session without asking. Both read "Claude Code, started ", so the person cannot tell there was a choice. The window always shows its picker; the fix is to show a picker when the re-read finds several.
    2. The CI path gate covers only mod/inbox* and mod/testing/**, while the proof drives controller.ts, bridge.ts, turns.ts and delivery.ts too. apps/hook/hooks/mod/** is the right gate.
    3. The two proof tests depend on their order: DecisionsProofTests must finish and Remove the source, or ProofTests fails at its first step. Each should start from a fresh install.
    4. The string "Your computer can't be reached right now." under the bar, after a failed sessions read, is not named in the deviations.
    5. The Decisions tab can spin forever when the decisions read fails with anything other than unreachable, unpaired or project_not_found and nothing is cached. It should show the error with Try Again.
    6. The decision switch mints a key per request, like M1's switch, where section 6 asks for one key per intent across retries. Low risk, because the command sets state.
    7. The mod engine harness's one failing test was red on main (confirmed by the review); fixed on main by PR 1793.
  • The deletion event (brief item 4): left out. It is not a two-line addition. The store rewrites its files on a deletion and writes no line, so an event needs a store hook, a new event kind on the stream (a change to exchange 7.9 in the contract), and handling in the window and the app. A phone still learns of a deletion at its next read of that thread (M1's gone state).

Deviations from the record or the contract, with the reason

  1. New message joins the thread it was written from, as the window and the store do (sendNewMessage writes reply_to: null, to: {host, session} in that thread). The brief's "becomes a thread in the list" is met this way: the thread moves in the list (the frame new-message-list), and the agent's answer lands in it. No contract change.
  2. 8.3 is a system popover anchored to the button, with its arrow. The record draws a card without one. The system popover dismisses on a tap outside and is read by VoiceOver as one dialog. At the accessibility text sizes the words outgrow a popover, so it opens as a full-height sheet that wraps and scrolls, and Reply instead stays reachable (review blocker 1; frame ax-8.3, asserted hittable in the proof).
  3. When the live sessions are read. The app reads them when the thread opens and every 10 s while it is open, as the window does. A press with one or none live re-reads first, so a session that just started is caught. If that re-read finds several where the last read had one, the sheet opens to the first, the thread's writer, and the button turns into the menu for the next press. A SwiftUI menu cannot be opened from code after a read. The 10 s is a refresh interval, not a limit.
  4. The decision sheet does not raise the keyboard on open, so it opens at the medium detent the record draws. The person taps a field to edit. At the accessibility text sizes the sheet opens full height, where the two fields need the room.
  5. Words the record does not draw:
    • The fields' placeholders, "The decision" and "Why".
    • Done is disabled while the decision is empty. The window shows "Write the decision first." after the press instead.
    • "Not kept. … Tap Done to try again." when Done cannot reach the computer.
    • The Decisions empty states: "No decisions yet" when the computer has no project yet, and "Nothing recorded in yet." under Settled.
    • The compose sheet's "Not sent. … Tap Send to try again." on an unreachable computer, and the Inbox's own words on a refusal (for example 409 session_not_live).
  6. The compose sheet keeps typed words. With words in it, the X asks "Discard this message?" and swipe-down is off. This follows the owner's bar ("a Send that cannot be lost").
  7. The New message key is kept across retries of the same words, and a new one is minted when the words are edited or after any definite answer (contract section 6; plannotator-ops's minor). It is not kept across an app restart, because the typed words are not kept either. A key that outlived its words would replay the first message for different words.
  8. The source subtitle under "Decisions" is the computer's name without the laptop glyph, the same as M1's Inbox title.
  9. "Replaced or retired" unfolds in place. The record draws the row folded with a chevron and not what it opens to. A replaced or retired row reads "Replaced · …" or "Retired · …", as the window's narrow rows do.
  10. The not-running words show the project's real folder. ~ is applied when the folder is under the computer's home. In the proof the projects live in a temp folder, so the frame shows a long /private/var/... path.
  11. The New message glyph is grey when no session is live; the record draws it tinted. Kept grey for parity with the desktop window, which greys the same button (ib-dis) and keeps it pressable, so the person sees there is no one to write to before pressing (review blocker 2).

With M7 on main (PR 1797, the Workspaces source)

M7 made the session serve two sources through SourceClient. M3's calls that only the computer's Inbox answers go through SourceSession.inbox (the device door, nil for Workspaces):

  • New message is not shown on a Workspaces thread, because Workspaces has no live sessions.
  • On Workspaces the decision switch keeps M7's behaviour: a tick that rides the Send, words drafted by the server, no card.
  • The Decisions tab on the Workspaces source reads "Decisions in Workspaces: This tab shows the decisions of your computer's Inbox. Decisions recorded in Workspaces are on its website." These words are not drawn. Plugging Workspaces into the tab is M7's named follow-up.
  • The Decisions title is inline, as M7 made the Inbox title (iOS 26 draws the title menu's chevron only on an inline title).
  • The shared "removed" and "can't reach" views carry M7's words for both sources.

Overlap with M2 (PR 1785)

  • ReplyBar.swift: one added line in bar. M2 restructures the same HStack, so the rebase keeps M2's field and adds the button before it.
  • SourceSession.swift: refresh() and a new block after setRecording. M2's edits are in refreshAll, open, send and a block after delete.
  • Models.swift: InboxAuthor's init and InboxQuestion.decisionDraft. M2 adds root to InboxThreadProject; I did not touch that struct.
  • Support.swift: When.dayWords, away from M2's colours.
  • proof.ts: the node:fs import line, the --only and data-dir guard lines (word for word as M2 has them), and a separate M3 block.
  • project.pbxproj: whichever lands second regenerates it with xcodegen generate.
  • My test is its own class with its own private helpers and uses the module-level Control, which exists on main and in M2's ProofSupport.swift. Moving it onto M2's ProofCase once both land is a follow-up.

Limits

None added. The two in play are the contract's own: the 10-minute pairing offer and the five tries, which the proof's pairing uses. The 10 s re-read of the live sessions is the window's refresh cadence, not a limit a person can hit.

@backnotprop
backnotprop force-pushed the feat/mobile-m3-decisions branch from d5c8075 to e10fd5e Compare October 8, 2026 16:43
The decision switch opens the decision card as a sheet (5.1) and Done keeps
its words and turns recording on; the Decisions tab shows a project's waiting
questions, settled decisions newest first and the replaced or retired ones
folded, each opening its thread (5.2); New message on the reply bar writes to
the one live session, picks from a system menu when several are live, and
shows the desktop's not-running words with Reply instead when none is (8.1 to
8.3). The proof drives live Claude Code sessions made of the mod's own code.
…he first test's cold-launch waits)

The not-running card opens as a full-height sheet that wraps and scrolls at
the accessibility text sizes, and the proof asserts Reply instead is reachable
there (frame ax-8.3). The first test carries the app's first launch on the
simulator: on a cold macOS runner that took 42 to 46 s to idle and the next
screens more than 30 s (run 37799098038), so its opening waits are 120 s.
…y class

proof.ts builds once, runs WarmUpLaunch alone (the app's first launch and
first pairing on the fresh simulator, which a cold CI runner makes take a
minute or more), then the rest. Every class's opening waits read
ProofWait.opening. The live Claude Code sessions start only before the New
message steps, so the runner is quiet while the app pairs. A New message
edited after a failed Send gets a new idempotency key.
On a cold runner the warm-up's first try can stop inside XCTest while the app
is still loading (run 37815033252 attempt 2: the pairing cover took over 120 s
to first appear). A try first removes a source a stopped try left.
New message is not shown on a Workspaces thread; there the decision switch
stays M7's tick, and the Decisions tab says its decisions are on the website.
The Decisions title is inline, as M7 made the Inbox title.
@backnotprop
backnotprop force-pushed the feat/mobile-m3-decisions branch from 85382d1 to 54f217a Compare October 8, 2026 18:24
@backnotprop
backnotprop merged commit f7d8539 into main Oct 8, 2026
30 checks passed
@backnotprop
backnotprop deleted the feat/mobile-m3-decisions branch October 8, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant