33 KiB
HAPI iOS
Native SwiftUI/UIKit client for HAPI, independent of web/ at the code level.
It shares the client protocol and
web-generated golden fixtures in shared/fixtures/.
Current capabilities
The app provides sessions/chat, approvals and questions, new sessions, attachments, files/Git, Scratchlist, standard dictation, usage/storage, settings and encrypted APNs notifications. It pairs with multiple hubs and keeps one active. Session lists support machine filtering, pinning and archive; sending to an inactive session can resume it and migrate the draft/navigation when the returned session ID changes.
Model, permission and Codex collaboration controls follow the session's agent/capabilities; usage/storage require the owner namespace. Rename, Delete and explicit Reopen have API wrappers but no current iOS UI. See the native app guide for platform differences, web-only features and everyday use.
Requirements
- Xcode 16 or newer with Swift 6 support.
- Deployment target: iOS 17.0.
- Runtime dependencies (SPM, declared in
Packages/HapiKit/Package.swift, used only by theHapiUItarget):swiftlang/swift-markdown(GFM parsing for the custom renderer) andraspu/Highlightr(code highlighting, kept behind a protocol so it is swappable).HapiProtocol/HapiClientstay dependency-free.
Build
Native chat scrolling architecture and acceptance checklist: Native transcript scrolling.
Open ios/Hapi.xcodeproj in Xcode and run the shared Hapi scheme, or from
the command line:
# App (simulator, no signing)
xcodebuild build -project ios/Hapi.xcodeproj -scheme Hapi \
-destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
# Package tests (macOS; protocol/client plus UI package tests)
swift test --package-path ios/Packages/HapiKit
CI runs package tests, the simulator build, and app-hosted transcript tests
on macos-15 via .github/workflows/ios.yml (triggered by changes under
ios/** and shared/fixtures/**).
Linux verification (no Mac needed)
ios/scripts/linux-test.sh compiles and tests the non-UI targets
(HapiProtocol + HapiClient and their test suites, including the
shared/fixtures golden suites) in a Swift Linux container:
ios/scripts/linux-test.sh # full swift test in docker
ios/scripts/linux-test.sh --filter Chat # extra args pass through to swift test
Requires docker; defaults to the swift:6.1-noble image
(HAPI_SWIFT_IMAGE overrides). The script rsyncs the package and the
fixtures into a repo-depth staging dir (HAPI_LINUX_STAGE overrides;
kept between runs so builds are incremental) and mounts it at /work,
so the tests' #filePath-relative fixture resolution works unchanged.
Always edit the real worktree files — every run re-stages them.
HapiUI (SwiftUI + swift-markdown + Highlightr) cannot build on Linux;
Package.swift drops the UI product/targets and their dependencies
under #if os(Linux), and the script leaves those sources out of the
staging copy. The few Darwin-only APIs in HapiClient are conditionally
compiled (#if canImport(Security) for the Keychain store — Linux tests
use InMemoryCredentialStore through the CredentialStoring seam;
#if canImport(CryptoKit) for snapshot-file digests with an FNV-1a
fallback; #if canImport(FoundationNetworking) for URLSession types and
a delegate-based SSE transport where URLSession.bytes is unavailable).
Protocol conformance fixtures
Run package tests from a full repository checkout. Test paths resolve
shared/fixtures/ relative to #filePath; copying only the package loses that
context. The Linux script stages the package and fixtures at the required depth.
| Suite | Coverage |
|---|---|
HapiProtocolTests |
Wire decoding, mode catalogs, versioned patches, and normalize → reduce → group projections against the golden fixtures. |
HapiClientTests |
Pagination fixtures through the message-window controller, chat pipeline/interactions, REST requests, authentication, SSE recovery, stores and push. |
HapiUITests / HapiTests |
Renderer logic and app-hosted layout, interaction, recycling and transcript behavior on supported Apple toolchains. |
Fixture conformance pins protocol/state semantics, not identical web/native
presentation. When changing fixture inputs or generation, run
bun run gen:fixtures at the repo root and include generated changes; never
hand-edit fixtures. See fixture guidance and
UI development for targeted app-hosted checks.
New-session directory regression checks: ios/scripts/linux-test.sh --filter 'NewSession|RemoteDirectoryBrowser' covers path queries and browser navigation;
HapiTests/NewSessionDirectoryTests covers the form's defaults, roster refresh,
offline machines and spawn validation. On a device, verify home → parent → a
project outside home, explicit workspace-root prefixes, ~/ and hidden-directory
completion, and clearing the input while machine health updates arrive. A
restored or selected offline machine stays selected until the user chooses an
online machine; its old path is never silently moved to another host.
Pairing
The app supports multiple hubs with one active selection. See the auth contract for the wire rules.
- Manual entry: start
hapi hub --relay, then Enter Manually → HTTPS hub address and access token → Pair. The address starts empty, with a separate protocol menu defaulting to HTTPS; type the domain/IP and optional port without a scheme. Pasting a full HTTP(S) URL updates that menu. A full companion or web pairing link pasted into either field fills both values for review — only submitting the form starts a connection. For a source-treebun run devhub, put an HTTPS reverse proxy or tunnel in front oflocalhost:3006; a physical phone must reach that endpoint. The app checksGET /health, requiresprotocolVersion == ProtocolVersion.supported, exchanges the token withPOST /api/authand stores credentials in the Keychain. - QR scan: the in-app scanner accepts both hub QR forms —
hapicompanion://bind?hub=…&code=…and the web direct-access URLhttps://<web>/?hub=…&token=…. Web Settings → Companion pairing also displays the companion QR. - Deep link: opening
hapicompanion://bindpresents pairing confirmation; an already-paired hub can be selected directly. - Simulator: use manual entry because
DataScannerViewControlleris unavailable. Camera pairing is optional on physical devices too. - Sign out: home → hub menu removes that hub's credentials and falls back to another paired hub or pairing. A rejected access token, or a freshly issued JWT rejected again, triggers re-pairing. Temporary network/5xx refresh failures retain credentials.
The parser accepts HTTP URLs. Manual entry uses the selected protocol
(default HTTPS); HTTP must be selected explicitly or supplied in a full URL
or pairing link. The form warns that HTTP is unencrypted and may be restricted
by iOS. It never guesses HTTP for a local address or downgrades after a failed
HTTPS connection. Input acceptance is not a transport exemption.
Hapi/Info.plist and the project build settings declare no ATS exceptions,
including no NSAllowsLocalNetworking. Use HTTPS endpoints for a reliable
pairing setup; HTTP behavior remains subject to system network policy.
Manual app-layer acceptance: pair → kill/relaunch → background/foreground →
pair a second hub and switch → sign out → scan both QR forms → open a deep
link for unpaired/paired hubs. Also check that transient hub failures preserve
pairing, while a rejected rotated token shows the sign-out banner.
Manual-entry form and system-paste behavior is covered by app-hosted
ManualPairingFormTests and ManualEntryPresentationTests; pure pairing
and auth behavior is covered by the package tests; this checklist is for the
app wiring and platform interaction.
Push notifications
End-to-end encrypted APNs push; wire details live in the native push contract. Official builds use the hub's default push relay. Self-signed builds need matching provider credentials as described below.
How it works
- Registration. After the first successful pairing the app asks for
notification permission (standard alert/sound/badge prompt — the Android
timing: never on a pristine unpaired install), registers with APNs, and
sends
POST /api/devices/register{token: <hex APNs token>, platform: "ios", deviceId: <stable UUID>, pushKey: <base64 32-byte key>}to every paired hub — each hub pushes independently for its own namespace. Registration re-runs on every app start and token rotation (cheap upsert; heals reinstalls and hub-side pruning), and Settings → Notifications has a manual Re-register push. Sign-out sends a best-effortDELETE /api/devices/register {token}from a credentials snapshot taken before the Keychain wipe. There is no background retry queue (the Android WorkManager part has no iOS equivalent) — the next trigger heals transient failures. - E2E envelope. The hub never sends plaintext through APNs. The wire
notification is
{aps: {mutable-content: 1, alert: <generic>}, hapi: {v: 1, e: <envelope>}}whereeisbase64(nonce[12] || AES-256-GCM ciphertext || tag[16])over the FCM data-contract JSON, keyed by this install'spushKeywith AADhapi-push-v1. Whether the hub delivers via direct APNs or a relay is entirely hub-side — the app only ever registers and decrypts. - Notification Service Extension. The
HapiNotificationServiceappex decrypts on device: reads the push key from the shared Keychain access group ($(AppIdentifierPrefix)run.hapi.companion.push, after-first-unlock so lock-screen pushes decrypt), swaps in the real title/body, stamps the action category, and stores the decrypted fields inuserInfofor the tap/action handlers. Undecryptable payloads deliver the generic alert unchanged. The appex links nothing beyond the SDK — it carries an SDK-only copy of the HapiKitPushEnvelopedecrypt, kept honest by the shared test vector. - Actions.
permission-request→ Allow / Deny;readyandtask-notification→ inline Reply.input-requestpreviews the first question, remaining question count and session name, with tap-to-open only (no approval or Reply action). Apple Watch mirrors the preview; answer on the phone. Handlers run in the notification delegate's async completion and resolve the owning hub Android-style (active hub first, then the roster; 404 "Session not found" / 403 = try the next hub) — approve/deny post{}, reply posts{text, localId}. Failures surface as a local notice instead of vanishing. A tap deep-links to the session chat; a push for the chat currently on screen is suppressed (the in-app SSE stream is already showing it).
Layer map: HapiClient/Push/ (envelope + payload + key, Linux-tested),
Endpoints/DeviceEndpoints.swift (register/unregister),
Hapi/Models/PushCoordinator.swift (registrar + delegate + action runner),
HapiNotificationService/ (the appex). The AES-GCM decrypt and the contract
test vector are verified by HapiClientTests/Push/* — structural checks run
in the Linux container, the CryptoKit vector/tamper tests on Darwin CI.
Self-signed builds with push
- Select a paid/organization team under Signing & Capabilities for both
the
Hapiapp andHapiNotificationServiceextension. Use bundle IDs registered to that team. Free personal teams cannot sign push entitlements. - Keep the same Keychain access group in both targets:
$(AppIdentifierPrefix)run.hapi.companion.push. The app also declaresaps-environment: development; distribution signing determines the final entitlement. The extension shares the key throughkeychain-access-groups, not an App Group container. - Configure the hub with
HAPI_IOS_PUSH=apnsandAPNS_KEY_P8_PATH,APNS_KEY_ID,APNS_TEAM_ID, andAPNS_BUNDLE_IDmatching the signed app. SetAPNS_ENV=sandboxfor development tokens orproductionfor distribution tokens. Alternatively use a self-hosted push relay with matching provider settings. The official relay cannot send to an arbitrary self-signed app. See transport configuration.
APNs availability on a simulator depends on the host/runtime and signing
setup. Use xcrun simctl push <simulator> <bundle-id> <payload.json> for local
notification injection, with hapi.e encrypted using that install's push key.
This checks local handling, not provider delivery. Verify registration,
background/lock-screen delivery and actions on a signed physical device.
Layout
| Path | Responsibility |
|---|---|
Hapi.xcodeproj/ |
App, notification extension and app-hosted tests. App sources use Xcode synchronized folders. |
Hapi/Models/ |
AppModel pairing/navigation, active HubSession, per-chat ChatSession, and PushCoordinator. |
Hapi/Features/ |
SwiftUI screens and app models for pairing, sessions, chat, new sessions, files, Scratchlist and settings; UIKit transcript hosting. |
Packages/HapiKit/Sources/HapiProtocol/ |
Foundation wire models, catalogs, pairing parser, versioned patches, chat reduction, message-window logic and Git parsers. |
Packages/HapiKit/Sources/HapiClient/ |
Auth/REST/SSE, stores and disk snapshots, message windows, chat interaction, attachments, dictation, push and testable feature logic. |
Packages/HapiKit/Sources/HapiUI/ |
Reusable Markdown, syntax highlighting, diff rendering, theme and typography. |
HapiNotificationService/ |
SDK-only extension that decrypts APNs content using the shared Keychain key. |
Packages/HapiKit/Tests/, HapiTests/ |
Package and app-hosted verification. |
The app owns navigation, lifecycle and screen presentation; HapiKit supplies
protocol, transport, testable feature logic and reusable UI. HapiProtocol and
HapiClient build without the UI dependencies on Linux.
The active hub maintains a global SSE connection; an open chat adds a
session-scoped connection. Chat event handling is ordered through the window
actor. Preserve per-hub credentials/drafts, version watermarks, resume cursors
and superseding-session navigation when changing this wiring. The transcript
uses AnchoredTranscriptList (UICollectionView with SwiftUI hosting) and
prepares Markdown off the main thread; see
native transcript scrolling.
Scratchlist workflow
The composer tray toggles a session-local chat / scratchlist destination.
Scratchlist mode shows one recent draft, or just a header while the input is
focused or when using accessibility text sizes, and an explicitly labelled
Save draft action. Tap the header to open the full list, or × to return to
chat. Closing the drawer preserves input; taking a draft or accepting a queue
send returns to chat mode. The queue remains a
separate, automatically delivered surface.
Text and attachments park as one snapshot. Failed saves retain input and retry the same entry ID. Taking a saved attachment creates a borrowed hub reference, without resuming the session; an explicit chat send stages it to the active/resumed session's upload directory. Queue actions do not consume the composer and use a stable local ID for the saved entry version. A failed post-acceptance deletion retries removal only. The full inventory uses one navigation stack for reading and transactional editing, with discard guards and editor identities protecting against late uploads.
Inventory rows show two lines of text, an attachment count, Take draft and a menu for queueing, editing, copying and deleting. Pull down for search (including attachment filenames); full filenames and relative timestamps live in the detail view. The editor's + menu offers photos and files. Empty states and routine chrome avoid explanatory paragraphs; failures stay inline with their recovery action, without a duplicate toast.
Regression suites: package ScratchlistComposerWorkflowTests,
ScratchlistAttachmentFlowTests, ScratchlistStoreTests and
ComposerAttachmentsTests; app-hosted ScratchlistScreenModelTests and
ScratchlistPresentationTests (including transcript-anchor preservation).
The presentation suite attaches light/dark, compact, accessibility, inventory
and editor renders. Manually check keyboard/VoiceOver, conflict choices,
photo/file retry, and inactive-session sending on a connected device.
UI development
Localization catalog
Hapi/Resources/Localizable.xcstrings is manually managed, including every
entry's "extractionState" : "manual". Keep SWIFT_EMIT_LOC_STRINGS = NO for the
app/extension in Debug and Release. That setting disables compiler extraction;
it does not prevent the Xcode editor from synchronizing or saving a catalog.
Unspecified ownership can still turn entries into stale during synchronization.
Use Xcode's native catalog formatting (key order, spacing, escaping), not a generic JSON formatter. After adding or editing translations, run from repo root:
python3 ios/scripts/localizations.py --check # read-only ownership check; no Xcode needed
python3 ios/scripts/localizations.py --fix # macOS/Xcode: mark manual, normalize native format
The normalizer uses xcstringstool on a temporary copy, verifies that all keys,
translations, plural variations and comments survive, and writes only if needed.
It never deletes real entries: review/remove unwanted auto-extracted additions
before --fix, rather than silently making them permanent. Keep dynamic values
and decorative text verbatim in SwiftUI where they are not localization keys.
CI checks manual ownership, tests normalization idempotence, and verifies that
building/testing does not rewrite the catalog. Formatting is delegated to the
installed Xcode rather than reimplementing its ordering in Python; --check
does not enforce one Xcode version's byte-level formatting on another version.
After migrating an existing checkout, use Product → Clean Build Folder if
Xcode still synchronizes old extraction results, then rebuild/reopen and inspect
the diff. Do not hide the file with .gitignore, skip-worktree, or restore it
unconditionally: real translation edits must remain visible and committed.
iPad navigation and resizing
iPad uses a two-column NavigationSplitView: sessions in the sidebar and a
separate detail navigation stack for chat → files/viewer/process pages. The
sidebar requests 280–360pt (320pt ideal); the system collapses the same view
hierarchy in compact windows. iPhone keeps its single NavigationStack.
Cold starts and hub switches show Select a session until the user chooses
one; notifications and new sessions can open an ID before its list row arrives.
Selection and nested navigation are in memory, not restored across launches.
SessionNavigationState owns selection, detail path and column visibility;
neither window geometry nor sidebar filters may clear them. Reopening the
selected session preserves its file page. A different/superseding session resets
the entire detail stack, but a late superseding callback cannot replace a chat
the user has since selected. Only explicit removal events or successful archive
operations clear the current selection, never optimistic list removal/rollback.
The detail's identity is hub/session-based, so resizing preserves the existing chat, draft, attachment tray and transcript anchor. Native menus/sheets remain native; the hub sign-out and attachment dialogs are anchored to their buttons. The explicit scene manifest disables additional HAPI windows, not Split View/Stage Manager with other apps. Do not enable scene-manifest generation (which generates multiple-scene support) or require full screen. Multiwindow, third-column inspectors, keyboard shortcuts and drag/drop are not implemented.
SessionNavigationTests covers navigation/removal policy and the built scene
manifest. SessionSplitPresentationTests runs on an actual iPad simulator and
uses the production split shell/list plus a real, non-networked chat specimen:
selection, compact programmatic opening, nested Back, filtering, resizing,
reading anchors, draft/attachment retention and surface/subscription lifetime.
CI keeps the iPhone suite and adds iPad Air 11-inch (M2). Run it locally with:
HAPI_TEST_DEVICE_TYPE=com.apple.CoreSimulator.SimDeviceType.iPad-Air-11-inch-M2 \
ios/scripts/test-transcript.sh \
-only-testing:HapiTests/SessionNavigationTests \
-only-testing:HapiTests/SessionSplitPresentationTests
Optional specimens: set TEST_RUNNER_HAPI_IPAD_CAPTURE to a new temporary
directory; these are synthetic test conversations, not release screenshots.
Before release, manually verify mini/11/13-inch portrait and landscape,
one-third/half-screen and Stage Manager resizing, keyboard docking/floating and
hardware-keyboard transitions, light/dark, Chinese/English, Dynamic Type and
VoiceOver. Check settings/new-session/Scratchlist/inspection sheets while
resizing, notification navigation, and background/foreground reconnection.
Reading typography
HapiUI separates color palettes (HapiTheme) from resolved Dynamic Type
metrics (HapiTypography). Install .hapiTypography() at a presentation root,
outside AnchoredTranscriptList; hosted rows inherit those metrics. Do not
scale the resolved values again. Body/user/composer text starts at 16pt,
inline code at 15pt, code/diffs/terminal at 14pt, and captions at 12pt.
Body and code add 3pt and 2pt of scaled inter-line spacing respectively.
The transcript and composer share a centered, at-most-720pt reading column with 16pt minimum side margins. Font, Bold Text, locale, and effective width changes invalidate height measurements while preserving the reading anchor. Ordinary streaming updates retain unchanged hosting roots and measurements.
User messages stay fully expanded through 8,000 characters and 120 source lines, even when they span multiple screens. Only larger payloads fold to a preview bounded to 2,000 characters / 24 lines. The folding threshold is separate from the preview budget; both bound the actual text passed to layout. View full message opens a screen-owned reader with one 4,000-character / 80-source-line part mounted at a time, previous/next navigation, and exact full-content copy. Paging preserves Unicode and whitespace without scanning the entire payload on open. The reader survives cell recycling and pauses hidden history/tail following; closing it preserves the reading position. Stored/sent messages are never truncated.
The UIKit transcript suite covers typography changes, recycling, shrinking text, tablet/phone widths, and tail following. Optional deterministic visual specimens cover light/dark/OLED, mixed Chinese/English, code, tables, diffs, approvals, and the actual composer. They use a non-networked test interactor; they are not live conversations or App Store screenshots. Capture into a new temporary directory, never over the release gallery:
TEST_RUNNER_HAPI_TYPOGRAPHY_CAPTURE=/tmp/hapi-typography-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/TypographySnapshotTests
Session settings
The chat gear presents a single-page model/effort and permission/collaboration form. All selections use native menus; permission explanations, high-risk indicators, model-loading/retry states and update feedback remain in the form. Changes apply immediately; Done only dismisses. On regular-width windows the production toolbar button anchors an iPad popover; compact windows adapt that same presenter to a medium/large sheet (large for accessibility text).
Run the configuration and real-presentation tests on both phone and tablet:
ios/scripts/test-transcript.sh \
-only-testing:HapiTests/SessionConfigTests \
-only-testing:HapiTests/SessionConfigPresentationTests
# Pick a device type supported by your installed runtime:
# xcrun simctl list devicetypes
HAPI_TEST_DEVICE_TYPE=com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M5-12GB \
ios/scripts/test-transcript.sh \
-only-testing:HapiTests/SessionConfigTests \
-only-testing:HapiTests/SessionConfigPresentationTests
The script creates and deletes an isolated simulator. Set
TEST_RUNNER_HAPI_SESSION_CONFIG_CAPTURE to a new temporary directory for
non-networked specimens (not App Store screenshots). A widened phone window
is not a substitute for the native iPad anchor test. For AXe-driven menu and
Done checks, run only
SessionConfigPresentationTests/testNativeDevicePresentationIsAnchoredOnIPadAndAdaptsOnIPhone
with TEST_RUNNER_HAPI_SESSION_CONFIG_INTERACTIVE_SECONDS=180; leave the panel
open when the pause ends. Manually check rotation and Split View/window resizing,
menu checkmarks and risk announcements, and that closing preserves the chat's
draft and reading position.
Tool inspection
Inline approvals use a neutral card with a quiet pending/submitting/handled
status, a full-input link, and adaptive action rows. ChatActionButtonStyle
owns the complete 44pt minimum target (10pt corners); do not add system bordered
button padding or another minimum label height. Approvals, question navigation
and plan actions share only this style, never their submission semantics.
Queued-message actions also keep 44pt targets and stack when the column is too
narrow. Native alerts, sheets and form controls retain their system styling.
PermissionActionPresentationTests covers flavor gates, action geometry, busy
and already-handled states, failures, themes and large text. To capture isolated,
non-networked approval specimens, set TEST_RUNNER_HAPI_APPROVAL_CAPTURE to a
new temporary directory and run it through ios/scripts/test-transcript.sh.
Plan proposals (ExitPlanMode / exit_plan_mode) are reading documents, not
activity summaries: their complete input.plan Markdown stays visible in the
conversation, before any approval controls. The same renderer is used in the
inspector; null output does not show a misleading "No output" placeholder.
Shared Codex proposals expose Implement plan and Continue planning only
when the active session's agentState.codexPlanProposalId matches the tool-call
id. Implementation uses the dedicated plan endpoint, not permission approval;
continue hides that proposal’s action menu locally and focuses the composer,
preserving its draft and plan mode without sending a message. The plan document
remains readable, and a new proposal gets a fresh menu. Pending/error state
survives row recycling. Withdrawn, historical and child proposals stay read-only
(an outstanding operation/error can still be shown).
Plans are prewarmed in the chat Markdown cache and never use the ordinary
tool-output preview/paging budget. The inspector retains raw fields under Source.
Tool summaries open a native large sheet instead of expanding their output inside the conversation. Tool groups also stay as one summary row: tapping one opens a native lazy list in the same inspector. Calls remain chronological, with each new presentation initially positioned at the latest tool. Streaming never scrolls the list; Latest tool explicitly returns to its end. Details push inside the same sheet, retaining the list position on Back. Both levels keep a toolbar Close action and native swipe dismissal. The inspector resolves stable group/tool IDs from live data; output-only changes do not reconfigure unchanged group summaries in the transcript. Group headers prioritize total calls over category counts. File/image summaries show the action and basename; commands use a bounded preview. Success is quiet, while running/errors remain visible; every row keeps a 44pt target. Edits show their recorded input, with a separate View current file action. Task/Agent sidechains open a process page; approvals and question answering remain in the conversation/process, not in the read-only inspector.
Question inspectors show recorded selections, custom answers and notes with
Markdown questions/options. request_user_input also restores answers from
historical results; live permission answers take precedence. Answered cards
avoid duplicate results, but retain errors and the full input/result/answers
under Source. Answer submission remains in the conversation.
Synchronous questions use a dedicated inline card, not the orange approval
footer. Only one question is shown at a time: single-selection taps advance
to the next question, while multiple-selection and text questions use Next
question. Previous question retains all choices and notes; the last step
always requires Submit answer. Recommended labels are display-only badges,
never default selections or rewritten wire values. Other-answer/note fields
expand on demand; text-only questions and prefilled drafts show them immediately.
Codex choice questions with isOther: true also offer None of the above.
Selecting it stays on the current question and focuses optional notes; empty
notes are valid. Its wire value remains None of the above in every language,
and recorded answers/notes appear in summaries and details. Requests without
isOther (including Pi and MCP forms) keep their existing choices.
All form state survives transcript-cell recycling for the retained request.
Successful records collapse to answer summaries; missing recorded answers are
shown as handled, not inferred from local drafts. Ordinary approvals retain their approval footer.
Question tests include pure navigation/answer-building checks and app-hosted layout/recycling specimens (fake data; no live agent or saved credentials):
TEST_RUNNER_HAPI_QUESTION_CAPTURE=/tmp/hapi-question-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/QuestionAnswerDraftTests \
-only-testing:HapiTests/QuestionCardPresentationTests
Inspection pauses transcript tail-following and hidden history paging, without opening another SSE subscription. Closing returns to the reading anchor; opening/closing at bottom does not itself show Back to latest. The button appears when the transcript is far enough from bottom (or the live tail has been trimmed), and explicitly resumes following. Trimmed records remain visible as labeled, read-only snapshots; missing groups retain their last membership, without switching to another group. Incomplete history is labeled and can be loaded from the conversation after closing the inspector. Large text is loaded in 20,000-character parts and can be copied in full; large diffs use paged source instead of eager rows.
The inspector recognizes namespaced command/script/patch calls. File reads use source-language highlighting; web/agent prose uses Markdown (large documents fall back to paged source). Common nested result envelopes are unwrapped, with command exit/status metadata kept visible. Source reveals the original input/result, including fields not shown in the preview; mixed text/media results stay JSON instead of losing non-text blocks.
The app-hosted suite covers selection, live updates, native sheet presentation, 2/42/240-call lists, initial positioning, detail navigation, surface handoffs, Unicode paging, and reading-position preservation. Native swipe gestures and release-device animation smoothness still need manual acceptance. Transcript specimens run the real ChatModel/ChatTranscriptView with fake HTTP and closed loopback SSE; sheet specimens are non-networked. Both use deterministic test records, not live sessions or App Store screenshots. Capture into a fresh directory:
TEST_RUNNER_HAPI_TOOL_CAPTURE=/tmp/hapi-tool-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/ToolInspectionPresentationTests \
-only-testing:HapiTests/ToolTranscriptPresentationTests
Home filtering
Home keeps a fixed Sessions title: hub switching on the leading edge, Filters and New Session on the trailing edge. The native menu currently offers machine single-selection; only applied filters add a summary line. Filters are transient per home/hub and never select a new session's machine. Options/counts come from all session summaries, including historical machines; the online roster supplies names only. Missing names use a labeled short ID. Session-count updates do not reorder options or reset the list's scroll position.
App-hosted filter tests use observable in-memory stores (no network or pairing). Optional layout captures are test specimens, not live or App Store screenshots:
TEST_RUNNER_HAPI_HOME_CAPTURE=/tmp/hapi-home-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/SessionListFilterTests \
-only-testing:HapiTests/HomeFilterPresentationTests
Notes
- The
hapicompanion://URL scheme is registered viaHapi/Info.plist, alongside the camera (QR pairing + attachment capture) and microphone (dictation) usage strings; everything else is generated throughGENERATE_INFOPLIST_FILE+INFOPLIST_KEY_*build settings. The modern out-of-processPhotosPickerneeds no photo-library permission, so there is noNSPhotoLibraryUsageDescription. run.hapi.appis the bundle id; signing isAutomaticand CI builds withCODE_SIGNING_ALLOWED=NO.- CI uses the runner's default Xcode; each job prints
xcodebuild -versionfirst so failures are attributable to a toolchain bump.