Imported from hearnadam/remote-control (
AGENTS.md). Install upstream withnpx skills add hearnadam/remote-control. Copyright stays with the author.
AGENTS.md
Project Shape
- This is an Xcode-only iOS app; the project is
RemoteControl/RemoteControl.xcodeprojand the only scheme isRemoteControl. The app's bundle ID isai.hearn.RemoteControl. - App sources live in
RemoteControl/RemoteControl/; unit tests live inRemoteControl/OpenCodeTests/(targetRemoteControlTests); there is no Swift Package manifest. - Build settings enable
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; be explicit before moving work off the main actor.
Commands
- List schemes/destinations:
xcodebuild -project "RemoteControl/RemoteControl.xcodeproj" -listandxcodebuild -project "RemoteControl/RemoteControl.xcodeproj" -scheme RemoteControl -showdestinations. - Simulator unit tests:
xcodebuild -project "RemoteControl/RemoteControl.xcodeproj" -scheme RemoteControl -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:RemoteControlTests test. - Device build:
xcodebuild -project "RemoteControl/RemoteControl.xcodeproj" -scheme RemoteControl -destination 'platform=iOS,id=00008150-000C70D03A40401C' -derivedDataPath "RemoteControl/DerivedDataDevice" build. - Deploy to the connected phone after building:
xcrun devicectl device install app --device 00008150-000C70D03A40401C "RemoteControl/DerivedDataDevice/Build/Products/Debug-iphoneos/RemoteControl.app"thenxcrun devicectl device process launch --device 00008150-000C70D03A40401C ai.hearn.RemoteControl. - Xcode may report the same phone as
00008150-000C70D03A40401Ceven ifdevicectl list devicesshows a different CoreDevice UDID; prefer the ID fromxcodebuild -showdestinationsforxcodebuild. - Lint:
swiftlintfrom the repo root (config in.swiftlint.yml).swiftlint --fixautocorrects safe style violations (opening-brace spacing, trailing commas, colon spacing, etc.). Install viabrew install swiftlint.
Build Artifacts
- Do not commit
RemoteControl/DerivedData*orRemoteControl/build/; remove ad-hoc device derived data before committing if it appears ingit status. - Root
.gitignoreignores rootDerivedData/andbuild/, but nested derived-data directories can still show up if created underRemoteControl/with nonstandard names.
Chat Scroll Behavior
- The message list should use
defaultScrollAnchor(.bottom)plus one derivedisPinnedToBottomstate fromonScrollGeometryChange; do not replace this with send-specific state machines or repeated delayed animatedscrollTocalls. - Initial transcript hydration must not run animated
scrollToor whole-list message animations. Let the default bottom anchor position the first non-empty load, then enable pinned-bottom corrections only after the initial messages have rendered. - Auto-follow is only appropriate while pinned to bottom. Update
isPinnedToBottomfrom user scroll geometry, keep it true when the user taps the down button, and avoid fighting user scroll gestures. - Keyboard/safe-area changes can require a non-animated bottom correction after the first render; animated corrections during load or keyboard resize can cause jumps, black overscroll, or apparent freezes.
- Composer send should keep focus active so the keyboard stays up after sending; this is independent from transcript scroll state.
OpenCode Server API Contracts
- The iOS API model is in
RemoteControl/RemoteControl/OpenCodeAPI.swift; compare it against the sibling server repo../opencodebefore changing wire models. - Server SSE
/eventstreams objects shaped as{ "type": ..., "properties": ... }from../opencode/packages/opencode/src/server/routes/instance/event.ts; do not assume a nestedpayloadwrapper is the only format. - Permission events are
permission.askedwithPermission.Requestfields andpermission.repliedwith{ sessionID, requestID, reply }; they are notpermission.updated,permissionID, orresponse. - Permission replies should use
/permission/:requestID/replywith body{ reply, message? }; avoid the deprecated session endpoint/session/:sessionID/permissions/:permissionIDwith{ response }. - Prompt input parts sent by iOS are server
MessageV2.*PartInputdrafts: file attachments use{ type: "file", mime, filename?, url }, whereurlis adata:<mime>;base64,...URL.
Event Decoding
OpenCodeEventis a discriminated enum with one case perEvent.<type>in../opencode/packages/sdk/openapi.json(components.schemas.Event). The wire→case mapping lives inOpenCodeEvent.init(from:)inOpenCodeAPI.swift.- Payload structs match each schema's
propertiesfield-for-field (required vs optional from the spec). When adding a new server event: add the case, the payload struct, and the switch arm; pull a representative JSON fixture from openapi.json for the test. - Forward-compat: unknown
typevalues decode as.unknown(type:properties:)rather than throwing — adding a server event no longer breaks the client; we just don't react to it until we add a case. - Both flat (
{type, properties}) and nested ({payload: {type, properties}}) wrappers are accepted; the server uses both depending on which route emitted the event. - Decoder tests live in
RemoteControl/OpenCodeTests/OpenCodeTests.swift. Reducer tests for state transitions live inSessionReducerTests.swift.
Session State Architecture
- Session-scoped state is modeled as
SessionState(a value type inRemoteControl/RemoteControl/SessionState.swift) and transitioned bySessionReducer.reduce(_:_:sessionID:)(inSessionReducer.swift). - The reducer is pure: imports only
Foundation, noURLSession, noTask, no@Published, no logging. House rules are documented at the top ofSessionReducer.swift— keep them. - The view model holds
private var sessionStates: [String: SessionState]keyed by session ID. Reducer-modeled session projection events route throughevent.targetSessionID(selected:)regardless of which session is currently selected, so background sessions stay current. Do not route session events the reducer does not handle just to create empty cache entries. - The VM's
@Published var messages/pendingPermission/sessionStatus/hasMoreMessages/abortedMessagesare mirrors ofsessionStates[selectedSession?.id], refreshed bysyncFromState(). Mutations should go throughupdateState(for:_:)(or the reducer directly) — never assign to the published mirrors directly, or the cache and UI drift. SessionStatusmirrors serversession.status(idle/busy/retry) plus synthetic.errorfromsession.error. Connection state is not aSessionStatus;sessionStatus: Stringis only the legacy bridge that overlays disconnected/reconnecting for old views. Do not set it toconnecteddirectly.- Local optimistic user messages have
SessionState.localSendStatus:queuedmeans local-only and not visible to the server/model yet,sendingmeans the POST is in flight,acceptedmeans the server accepted but events/REST have not reconciled, andfailedmeans not confirmed by the server. The original payload (text, attachments, model, provider, directory) is mirrored inSessionState.pendingSendsso reconnect/retry can replay the request without re-reading the composer. Both maps are cleared by the reducer when the server echoes the message back (real ID swap) or removes it. - Queued messages auto-flush on every successful connect via
OpenCodeViewModel.flushQueuedMessages— one drain task per session, FIFO by transcript order, guarded byflushingSessionsso concurrent reconnects don't double-send. A hard send failure stops that session's flush (so we don't hammer a dead server) and the offending message lands in.failed; the user can long-press the bubble to Retry (returns it to.queuedand re-kicks the loop) or Delete (purges from cache without a server call)..sending/.acceptedare mid-flight and intentionally not user-actionable from the context menu — wait for the SSE swap. - When fetching messages for a background session (for example after an orphan
message.part.updated), resolve that session's directory override or storedsession.directory; do not useactiveDirectory, which belongs to the selected session. - Per-session caches survive session switches:
select()restores fromsessionStates[id]synchronously then merges the latest page in the background. Switching sessions no longer wipes transcripts.
Session / Message Wire Contract
The server stores sessions and messages in SQLite with event-sourced projections. The iOS client should treat local caches as mutable projections, not append-only logs.
Mutability
message.updatedandmessage.part.updatedare upserts: existing rows are replaced by matchingid.message.removedandmessage.part.removeddelete rows permanently.- iOS must handle removals or reconcile with the server’s latest page to avoid stale UI.
Forking
POST /session/:sessionID/forkclones messages/parts up to an optionalmessageIDinto a new child session.- Forks get new message/part IDs and set
parentIDon the new session. - They are not branches inside the same session.
Revert
- Revert prunes messages/parts after a target point and restores a snapshot.
- It emits removal sync events, shrinking visible history.
- After a revert, new prompts continue in the same session.
Pagination
GET /session/:sessionID/message?limit=&before=loads older messages with an opaque cursor.- There is no
aftercursor for “newer than cached.” - Refresh strategy: fetch the latest page, upsert matching IDs, append new IDs, and prune stale entries only when a removal/revert is known or after a full authoritative refresh.
Storage schema (source of truth)
SessionTable: id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url, summary_*, revert, permission, timestampsMessageTable: id, session_id, time_created, data (JSON blob ofMessageV2.Infominus id/sessionID)PartTable: id, message_id, session_id, time_created, data (JSON blob ofMessageV2.Partminus id/sessionID/messageID)TodoTable: session_id, content, status, priority, position- Messages/parts reference sessions with
onDelete: cascade