Repository navigation
Inbox mobile: decisions and New message in the iPhone app (M3) - #1791
Merged
Merged
Conversation
backnotprop
force-pushed
the
feat/mobile-m3-decisions
branch
from
October 8, 2026 16:43
d5c8075 to
e10fd5e
Compare
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
force-pushed
the
feat/mobile-m3-decisions
branch
from
October 8, 2026 18:24
85382d1 to
54f217a
Compare
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
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 callsdecisions(project:),keepDecision(message:key:draft:)(recording on with the card's words,draft: nullto keep the drafted words),sessions(thread:)andnewMessage(thread:session:body:idempotencyKey:), andDecisionWords, the Swift port ofdraftInboxDecisionTextanddraftInboxDecisionReason.Models.swift:InboxQuestion.decisionDraft; a publicInboxAuthorinit.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 ofInboxList.swiftso Inbox and Decisions share them.InboxTab.swift: the source subtitle moved ontoSourceMenu, 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;--onlyand 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 andProofWait.opening, which M1'sProofTestsopening waits read too),project.pbxproj(regenerated byxcodegen generate),README.md..github/workflows/ios.yml: the path gate gainsapps/hook/hooks/mod/inbox*andapps/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 inbuilt/, and the recording isM3-decisions-new-message.mp4. Contract exchanges 7.21 to 7.24 are covered, plus section 6's key rule for New message..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).Proofs
XCUITest against the compiled Inbox:
bun apps/ios/scripts/proof.ts --binary <plannotator compiled from this branch> --device "iPhone 17"(--only PlannotatorUITests/DecisionsProofTestsruns 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:plannotator inbox mcp;record_decisions, one of them retired through the window's route;ClaudeSessionfromapps/hook/hooks/mod/testing/claude-session.ts(the mod's ownPlannotatorModon 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:
GET /api/inbox/decisionshas no decision from that thread.$.prompt.submitgot the wake (submits: gateway 1, the other two 0). It answered withreply_tothe wake's id, and the answer shows in the same thread. The thread's row is in the list.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.tsbuilds once (build-for-testing), then runsPlannotatorUITests/WarmUpLaunchalone, 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'sDecisionsProofTestsfailed 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'sProofTestsfailure 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,DecisionsProofTestspassed in 174 s, and M1'sProofTestspassed in 254 s after it on the same simulator and Inbox. The log isproof-run.login 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 isregister > 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 underapps/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/ioskeeps its own version line).bun test scriptsunder a temp data dir: 211 pass, 0 fail. No TypeScript underpackagesorapps/hookchanged.gitleaks git . --log-opts=origin/main..HEAD: no leaks.proof.tsonly: argvspawnandspawnSync, and no request data reaches a file or network sink beyond the loopback window routes.xcodebuild build-for-testing: no warnings from our code.DecisionsProofTests, and M1'sProofTestson the same run.What it leaves out
research/reviews/REVIEW-PLANNOTATOR-MOBILE-M3-2026-10-08.md):mod/inbox*andmod/testing/**, while the proof drivescontroller.ts,bridge.ts,turns.tsanddelivery.tstoo.apps/hook/hooks/mod/**is the right gate.DecisionsProofTestsmust finish and Remove the source, orProofTestsfails at its first step. Each should start from a fresh install.project_not_foundand nothing is cached. It should show the error with Try Again.Deviations from the record or the contract, with the reason
sendNewMessagewritesreply_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 framenew-message-list), and the agent's answer lands in it. No contract change.ax-8.3, asserted hittable in the proof).409 session_not_live).~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.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 throughSourceSession.inbox(the device door, nil for Workspaces):Overlap with M2 (PR 1785)
ReplyBar.swift: one added line inbar. 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 aftersetRecording. M2's edits are inrefreshAll,open,sendand a block afterdelete.Models.swift:InboxAuthor's init andInboxQuestion.decisionDraft. M2 addsroottoInboxThreadProject; I did not touch that struct.Support.swift:When.dayWords, away from M2's colours.proof.ts: thenode:fsimport line, the--onlyand data-dir guard lines (word for word as M2 has them), and a separate M3 block.project.pbxproj: whichever lands second regenerates it withxcodegen generate.Control, which exists on main and in M2'sProofSupport.swift. Moving it onto M2'sProofCaseonce 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.