Files
hapi/ios/README.md
T

584 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HAPI iOS
Native SwiftUI/UIKit client for HAPI, independent of `web/` at the code level.
It shares the [client protocol](../docs/api/client-contract/index.md) 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](../docs/guide/native-apps.md)
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 the `HapiUI` target): `swiftlang/swift-markdown` (GFM parsing
for the custom renderer) and `raspu/Highlightr` (code highlighting, kept
behind a protocol so it is swappable). `HapiProtocol`/`HapiClient` stay
dependency-free.
## Build
Native chat scrolling architecture and acceptance checklist:
[Native transcript scrolling](../docs/native-chat-scrolling.md).
Open `ios/Hapi.xcodeproj` in Xcode and run the shared `Hapi` scheme, or from
the command line:
```sh
# 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:
```sh
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](../shared/fixtures/README.md) and
[UI development](#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](../docs/api/client-contract/auth.md) 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-tree
`bun run dev` hub, put an HTTPS reverse proxy or tunnel in front of
`localhost:3006`; a physical phone must reach that endpoint. The app checks `GET /health`,
requires `protocolVersion == ProtocolVersion.supported`, exchanges the
token with `POST /api/auth` and 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 URL
`https://<web>/?hub=…&token=…`. Web **Settings → Companion pairing** also
displays the companion QR.
- **Deep link:** opening `hapicompanion://bind` presents pairing confirmation;
an already-paired hub can be selected directly.
- **Simulator:** use manual entry because `DataScannerViewController` is
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](../docs/api/native-companion-contract.md). 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-effort `DELETE /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>}}` where `e` is
`base64(nonce[12] || AES-256-GCM ciphertext || tag[16])` over the FCM
data-contract JSON, keyed by this install's `pushKey` with AAD
`hapi-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 `HapiNotificationService` appex
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 in
`userInfo` for 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 HapiKit `PushEnvelope` decrypt, kept honest
by the shared test vector.
- **Actions.** `permission-request` → Allow / Deny; `ready` and
`task-notification` → inline Reply. `input-request` previews 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
1. Select a paid/organization team under *Signing & Capabilities* for both
the `Hapi` app and `HapiNotificationService` extension. Use bundle IDs
registered to that team. Free personal teams cannot sign push entitlements.
2. Keep the same Keychain access group in both targets:
`$(AppIdentifierPrefix)run.hapi.companion.push`. The app also declares
`aps-environment: development`; distribution signing determines the final
entitlement. The extension shares the key through `keychain-access-groups`,
not an App Group container.
3. Configure the hub with `HAPI_IOS_PUSH=apns` and `APNS_KEY_P8_PATH`,
`APNS_KEY_ID`, `APNS_TEAM_ID`, and `APNS_BUNDLE_ID` matching the signed app.
Set `APNS_ENV=sandbox` for development tokens or `production` for
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](../docs/api/native-companion-contract.md#transports-self-host-direct-apns-vs-official-relay).
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](../docs/native-chat-scrolling.md).
## 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:
```sh
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:
```sh
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:
```sh
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:
```sh
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):
```sh
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:
```sh
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:
```sh
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 via `Hapi/Info.plist`,
alongside the camera (QR pairing + attachment capture) and microphone
(dictation) usage strings; everything else is generated through
`GENERATE_INFOPLIST_FILE` + `INFOPLIST_KEY_*` build settings. The modern
out-of-process `PhotosPicker` needs **no** photo-library permission, so
there is no `NSPhotoLibraryUsageDescription`.
- `run.hapi.app` is the bundle id; signing is `Automatic` and CI builds
with `CODE_SIGNING_ALLOWED=NO`.
- CI uses the runner's default Xcode; each job prints `xcodebuild -version`
first so failures are attributable to a toolchain bump.