This project aims to stay aligned with the upstream OpenCode client architecture and interaction patterns from ~/opencode, especially the web/app implementation.
Build a native iOS client that follows upstream OpenCode behavior closely enough that future improvements can be guided by the existing OpenCode app patterns rather than ad hoc client-specific behavior.
Priority areas:
- One shared SSE/event pipeline
- Typed event handling
- Centralized bootstrap/hydration
- Project -> Session -> Chat navigation
- Session-local live state like todos/messages
- First-party UI for permissions, questions, todos, and tool activity
These local upstream files were identified as the main references for architecture and behavior:
~/opencode/packages/app/src/context/global-sdk.tsx~/opencode/packages/app/src/context/global-sync.tsx~/opencode/packages/app/src/context/global-sync/bootstrap.ts~/opencode/packages/app/src/context/global-sync/event-reducer.ts~/opencode/packages/app/src/context/sync.tsx~/opencode/packages/sdk/js/src/v2/gen/types.gen.ts
Upstream uses one shared SSE/global event owner and fans events out by directory.
Relevant upstream file:
packages/app/src/context/global-sdk.tsx
Current iOS direction:
OpenCodeIOSClient/API/OpenCodeEventManager.swift
Upstream uses generated discriminated event types in the SDK.
Relevant upstream file:
packages/sdk/js/src/v2/gen/types.gen.ts
Current iOS direction:
OpenCodeIOSClient/Models/OpenCodeModels.swiftOpenCodeTypedEventOpenCodeEventEnvelopeOpenCodeGlobalEventEnvelope
Upstream applies global and directory/session events through reducer helpers rather than scattering event mutation in views.
Relevant upstream file:
packages/app/src/context/global-sync/event-reducer.ts
Current iOS direction:
OpenCodeIOSClient/Models/OpenCodeStateReducer.swift
Upstream bootstraps global state and directory/session state separately.
Relevant upstream file:
packages/app/src/context/global-sync/bootstrap.ts
Current iOS direction:
OpenCodeIOSClient/API/OpenCodeBootstrap.swift
GET /project/currentworksGET /projectreturns known projects- Non-global projects are scoped by
directory globalis special:- bare
GET /sessionreturns global sessions GET /session?directory=/does not
- bare
- Project discovery appears implicit from directory selection/worktree state
PATCH /project/:id?directory=...updates an existing project, but does not create one- Project id for git repos appears tied to the repo's first commit hash
- Web flow appears to use directory search + session roots query + SSE
project.updated
- Scoped session list:
GET /session?directory=... - Scoped session creation:
POST /session?directory=... - Discovery/warm-up pattern observed:
GET /session?directory=...&roots=true&limit=55
- Source of truth is
GET /session/:id/todo - Todo tool message detail is useful for context/debugging, but should not be treated as the canonical todo state
- Hide todo strip when all items are
completed
Important corrections discovered during implementation:
- Permissions are not driven by
/tui/control/* - Correct live event:
permission.asked - Correct reply endpoint:
POST /permission/:requestID/reply - Initial hydration endpoint:
GET /permission - Actual permission list payload shape differs from earlier assumptions:
permissionpatternsalwaysmetadatasessionIDtool.messageIDtool.callID
- Initial hydration endpoint:
GET /question - Reply endpoint:
POST /question/:requestID/reply - Reject endpoint:
POST /question/:requestID/reject - Live events:
question.askedquestion.repliedquestion.rejected
- Upstream question payloads can omit defaultable fields:
multiplemay be missing and should default tofalsecustommay be missing and should default totrue
- Question live-event decoding is easy to break if the generic event envelope model does not carry question-specific fields like
questions
Important streaming findings:
- The client now receives raw SSE payloads correctly
- On-device event framing required parser adjustments beyond naive blank-line assumptions
- Upstream reducer behavior for
message.part.deltais simple append-to-field on an existing typed part - Reasoning vs answer text should be determined by the server-provided part
type(reasoningvstext), not by parsing streamed text content - Deltas that arrive before the corresponding
message.part.updatedshould be ignored rather than creating a placeholdertextpart, so reasoning streams are never misclassified as answer text - iOS still preserves accumulated text when a later empty
part.updatedwould otherwise wipe it - Typed-event decode failures used to fail silently at the SSE boundary; the event manager now logs dropped events into the existing debug log.
- Buffered visible-chat
message.part.deltaevents must project reducer-applied messages back into the activemessagesarray when flushed. A previous regression applied buffered deltas into directory sync state but calledapplyDirectoryEventState(..., updatesSelectedMessages: false), making streaming appear stopped until a later full refresh/reload.
When a live SSE event is visible in raw payloads but does not update UI state, suspect typed-event decode mismatch before suspecting view logic.
Common symptoms:
permission.askedworks in-chat butquestion.askeddoes not- bootstrap hydration via
GET /questionworks, but live question UI never appears - debug logs show raw events arriving, but no corresponding
question changedreducer log - the stream appears healthy, but the only new clue is a
drop event: untyped ...debug line
How to identify it:
- Compare the live SSE payload shape with
OpenCodeEventPropertiesand the target typed model inOpenCodeIOSClient/Models/OpenCodeModels.swift. - Check upstream generated SDK types in
~/opencode/packages/sdk/js/src/v2/gen/types.gen.tsfor optional vs required fields. - Look for asymmetry between similar event types.
Example:
permission.askedhad tolerant parsing whilequestion.askedoriginally required a strict decode. - Use the debug probe log and look for lines like:
drop event: untyped question.asked dir=/tmp/projectdrop event: invalid global envelope ...drop event: missing inner envelope dir=...
How to fix it:
- Ensure
OpenCodeEventPropertiesincludes the event-specific fields needed to reconstruct the typed payload. Example:question.askedneedsquestions. - Match upstream optional/default semantics in Swift models.
Example:
OpenCodeQuestion.multipleshould default tofalse, andcustomshould default totruewhen omitted. - Prefer tolerant decoding for event payloads that the server or upstream may evolve.
- Add regression tests for both:
- a valid payload with omitted optional/defaultable fields
- an invalid/incomplete payload that should surface as a dropped-event debug message rather than fail silently
Main project files:
OpenCodeIOSClient/API/OpenCodeAPIClient.swiftOpenCodeIOSClient/API/OpenCodeEventStream.swiftOpenCodeIOSClient/API/OpenCodeEventManager.swiftOpenCodeIOSClient/API/OpenCodeBootstrap.swiftOpenCodeIOSClient/Models/OpenCodeModels.swiftOpenCodeIOSClient/Models/OpenCodeStateReducer.swiftOpenCodeIOSClient/ViewModels/AppViewModel.swiftOpenCodeIOSClient/Views/RootView.swiftOpenCodeIOSClient/Views/ProjectListView.swiftOpenCodeIOSClient/Views/SessionListView.swiftOpenCodeIOSClient/Views/ChatView.swift
Navigation shape:
- Projects
- Sessions
- Chat
This is implemented with NavigationSplitView so it can adapt better across iPhone/iPad/macOS-style layouts.
Recent comparison against ~/opencode clarified several important gaps between the current iOS architecture and the upstream app architecture.
AppViewModelis still the dominant state owner and mixes:- bootstrap orchestration
- network hydration
- direct state mutation
- selection/reset logic
- optimistic UI state
- Reducer coverage is partial:
OpenCodeStateReducerhandles some global/session eventsOpenCodeStreamReducerhandles message/part streaming behavior- sessions, todos, selection, and many bootstrap transitions still mutate outside reducers
OpenCodeEventManagercurrently owns a single/global/eventstream, which is directionally correct, but upstream's key behavior is fanout bydirectoryfrom one shared owner.- The current iOS client still relies on fallback refresh/polling in places where upstream expects bootstrap plus reducer-driven live events to be the main source of truth.
- Typed event modeling in iOS is ahead of reducer application coverage; several modeled events are not yet fully applied, especially:
session.createdsession.updatedsession.deletedsession.difftodo.updatedmessage.removedmessage.part.removed
- Upstream has one shared SSE owner in
global-sdk.tsx. - Events are fanned out by
directory, with missing directory treated asglobal. - Bootstrap is explicitly split into:
- global bootstrap
- directory bootstrap
- State ownership is explicitly separated into:
- one global store
- one child store per directory
- session-local caches inside directory state
- Event application is reducer-driven rather than view-driven.
- Upstream coalesces noisy stream events and treats newer full
message.part.updatedstate as canonical over stale deltas. - UI components consume higher-level sync facades (
useGlobalSync,useSync) rather than mutating raw event state directly.
When behavior is unclear, prefer matching the upstream OpenCode client flow from ~/opencode over inventing new app-specific semantics.
In particular:
- Prefer one shared event manager over many listeners
- Prefer reducer-style state application over inline mutation
- Prefer bootstrap + live event sync over fallback polling
- Keep todos session-local
- Keep permissions/questions first-class and hydrated up front
- Keep project/session/chat separation explicit in navigation
The current refactor should continue in this order.
- Split store ownership
- Keep a small global/app store for:
- connection
- server health/config
- projects/current project
- shared readiness/error state
- Introduce directory-scoped state containers for:
- sessions
- selected session id
- session statuses
- messages/parts
- todos
- permissions
- questions
- per-directory hydration readiness
- Shrink
AppViewModel
- Move raw event application and canonical data mutation out of
AppViewModel. - Keep
AppViewModelas a higher-level facade/coordinator for:- bootstrap
- store selection
- user actions
- view-facing derived state
- Expand reducer ownership
- Extend reducer coverage so reducers own:
session.createdsession.updatedsession.deletedsession.statussession.diffwhere needed for previews/statustodo.updatedmessage.removedmessage.part.removed- permission/question lifecycle cleanup
- Preserve upstream streaming semantics where full
message.part.updatedpayloads establish canonical part identity/type andmessage.part.deltaonly appends to existing parts. - Keep the iOS guard that avoids wiping text on later empty
part.updatedunless upstream behavior proves it is unnecessary.
- Align bootstrap to upstream phases
- Phase 1: global bootstrap
- health
- config
- projects
- current project
- Phase 2: directory bootstrap
- sessions
- directory project/path
- permissions/questions
- session statuses
- Session hydration should become a narrower follow-up step, not the main place where canonical state is assembled.
- Keep one shared event manager, but route by directory
- Continue using a single SSE owner.
- Fan out global vs directory events into the relevant state container.
- Match upstream semantics by treating missing directory as global.
- Reduce fallback refresh logic
- Audit and gradually remove:
startLiveRefreshscheduleReload- broad post-send reload paths
- Only remove refresh paths after the corresponding reducer/event path is trusted.
- Expose a sync-style facade to views
- Views should consume derived project/session/chat state from a thin facade.
- Avoid direct mutation paths from views into raw arrays/maps held by
AppViewModel.
The long-term direction is for AppViewModel to become a coordinator/facade rather than the owner of canonical app data. It may route user actions, select the active stores, and expose view-facing derived state, but domain state should live in focused stores that can be hydrated, reduced, cached, and tested independently.
-
Connection/app shell state
- Current state includes
config,backendMode,isConnected,serverVersion,isLoading,errorMessage, recent servers, and saved-server sheet state. - Target owner:
ConnectionStoreorAppSessionStore. - Keep this global. It should coordinate global bootstrap and teardown without owning project/session/chat arrays.
- Current state includes
-
Project state
- Current state includes
projects,currentProject,selectedDirectory, project picker/search state, and create-project form state. - Target owner:
ProjectStore. - This store should expose the active directory scope used by directory/session stores.
- Current state includes
-
Directory/workspace sync state
- Current state is mostly
OpenCodeDirectoryState. - Target owner:
DirectoryStore, keyed by directory, with missing directory treated asglobal. - This should own directory bootstrap state, sessions, commands, statuses, pending interactions, and session-local child stores.
- Current state is mostly
-
Session list state
- Current state includes
directoryState.sessions,sessionPreviews,pinnedSessionIDsByScope,workspaceSessionsByDirectory, andpendingActionRunsBySessionID. - Target owner:
SessionStoreor a session-list slice insideDirectoryStore. - The session list should consume a prepared snapshot rather than assembling rows directly from unrelated
AppViewModelmaps.
- Current state includes
-
Chat session state
- Current state includes
directoryState.messages,cachedMessagesBySessionID,toolMessageDetails, selected-session hydration flags, and stream/transcript buffering. - Target owner:
ChatStoreper session, backed later by SwiftData/Core Data as a read-through cache. - Chat open should read cached messages immediately, then reconcile from server bootstrap and live SSE events.
- Current state includes
-
Composer state
- Current state includes
draftMessage,draftAttachments,messageDraftsByChatKey,composerResetToken, and active composer focus/streaming flags. - Target owner:
ComposerStore, scoped per session and persisted by server/workspace/session key. - Navigation should save/restore drafts through this store rather than mutating active draft fields directly.
- Current state includes
-
Model, agent, and command configuration
- Current state includes
availableAgents,availableProviders,defaultModelsByProviderID,newSessionDefaults,selectedAgentNamesBySessionID,selectedModelsBySessionID, andselectedVariantsBySessionID. - Target owner:
ModelConfigurationStore. - This store should provide defaults for new sessions and effective selections for sends/actions.
- Current state includes
-
Permissions and questions
- Current state includes
directoryState.permissionsanddirectoryState.questions. - Target owner:
SessionInteractionStoreor the interaction slice insideChatStore. - Permissions/questions are first-class pending user actions. They should be hydrated up front, reduced from live events, and cleaned up by lifecycle events per session.
- Current state includes
-
Todos
- Current state includes
directoryState.todos. - Target owner:
SessionTodoStoreor the todo slice insideChatStore. - Todos are session-local.
GET /session/:id/todoremains the source of truth, withtodo.updatedreducing the active cache.
- Current state includes
-
VCS/files state
- Current state includes
vcsInfo,vcsFileStatuses,vcsDiffsByMode,selectedVCSMode,selectedVCSFile,projectFilesMode, file tree nodes/children, selected file, file contents, and loading/error flags. - Target owner:
ProjectFilesStoreorVCSStore, scoped by project/directory/workspace. - Files/Git is a separate product surface from chat and should have its own loading lifecycle and cache.
- Current state includes
-
MCP state
- Current state includes
mcpStatuses,isMCPReady,isLoadingMCP,togglingMCPServerNames, andmcpErrorMessage. - Target owner:
MCPStore, scoped by active directory. - MCP status should be loaded and toggled independently of chat/session selection.
- Current state includes
-
Live Activities
- Target owner:
LiveActivityStore. - ActivityKit should consume session/chat snapshots from sync stores and manage ActivityKit tasks/state separately.
- Target owner:
-
Widgets
- Target owner:
WidgetSnapshotPublisher. - Widget publishing should be a side-effect of project/session/preview changes, not a responsibility of the main coordinator.
- Target owner:
-
Commerce and paywall
- Target owner:
CommerceStore. - Entitlements, usage metering, and paywall presentation are app-global business state. Session creation and send-message paths should query this store.
- Target owner:
-
Apple Intelligence local workspace mode
- Target owner:
AppleIntelligenceWorkspaceStore. - Treat this as a second backend that implements the same project/session/chat facade shape where practical.
- Target owner:
-
Debug probe and streaming diagnostics
- Target owner:
DiagnosticsStore. - Diagnostics should observe the event pipeline and transcript buffering without owning canonical chat state.
- Target owner:
-
Fun and Games
- Target owner:
FunAndGamesStore. - Feature-specific game state can annotate sessions, but should not live in the core app coordinator.
- Target owner:
- Wrap the existing
OpenCodeDirectoryStatein aDirectoryStorewithout changing behavior. - Split session-list and chat-session state out of that wrapper once callers route through the store.
- Move composer drafts and model/agent selections into dedicated stores.
- Move VCS/files and MCP into separate directory-scoped stores.
- Move Live Activities, Widgets, Commerce, Diagnostics, Apple Intelligence, and Fun/Games into side-effect or feature stores.
- A local database should be introduced as a read-through cache behind the sync stores, not as a replacement source of truth.
- Good cache candidates are projects, sessions, messages, message parts, todos, and pending permissions/questions.
- The OpenCode server remains canonical. Bootstrap reconciles cache state, SSE events update memory and cache, and full server responses win over stale local data.
- Persist reducer-applied state transitions where possible. Avoid adding database writes as another ad hoc mutation path inside
AppViewModel.
This project is being shaped iteratively from hands-on device feedback.
The working pattern so far:
- Implement the smallest real version of a feature
- Install on-device and test in the real app, not just simulator
- Use your feedback as product direction, not just bug reports
- When behavior is ambiguous, inspect the upstream OpenCode implementation first
- When server behavior is ambiguous, verify against the live API before guessing
Feedback should be treated in these buckets:
-
Architecture Example: one shared SSE manager, reducer-driven state, bootstrap before live events
-
Product semantics Example: projects are a navigation layer, not a filter
-
Interaction model Example: permissions replace the composer area, todos stay visible, sessions use Messages-style rows
-
Visual polish Example: glass treatments, spacing, send-button size, list density
-
Reality check Example: “this worked in another client”, “this session still has a pending permission”, “the todo list feels stale”
When possible, prefer adapting implementation to match:
- live server behavior
- upstream OpenCode client behavior
- your product intent for the native app
Preferred order of operations:
- Reproduce on device
- Verify server/API truth directly
- Compare with upstream
~/opencodebehavior - Add minimal instrumentation only when needed
- Remove or hide debug UI once the feature is understood
This project is meant to be tested frequently on your iPhone.
- Xcode installed on this Mac
- your Apple team/signing available in Xcode
- iPhone visible to Xcode by USB or network debugging
- Developer Mode enabled on device if required
Build for simulator:
xcodebuild -quiet -project OpenCodeIOSClient.xcodeproj -scheme OpenCodeIOSClient -sdk iphonesimulator buildBuild for device:
xcodebuild -quiet -project OpenCodeIOSClient.xcodeproj -scheme OpenCodeIOSClient -sdk iphoneos buildInstall on device:
xcrun devicectl device install app --device "<device-udid>" \
"<derived-data>/Build/Products/Debug-iphoneos/OpenClient.app"Important:
- do not install from a stale repo-local
DerivedData/Build/Products/...path unless that folder was the explicit-derivedDataPathfor the build you just ran - a stale
DerivedData/Build/Products/Debug-iphoneos/OpenCodeIOSClient.appcan still exist from pre-rename builds and will carry the wrong bundle identifier - prefer either the real
TARGET_BUILD_DIRfromxcodebuild -showBuildSettingsor the repo-controlled.derived-data-device/Build/Products/Debug-iphoneos/OpenClient.app
Launch on device:
xcrun devicectl device process launch --device "<device-udid>" br.com.rslabs.openclientRegenerate the Xcode project after adding/removing source files:
xcodegen generateUse the local signing override on this machine:
INCLUDE_PROJECT_LOCAL_YAML=1 xcodegen generate- Device availability can drop in and out; always verify with:
xcrun xcdevice list-
If the phone is visible but unavailable, common fixes are:
- unlock the device
- reconnect USB once
- ensure same LAN for wireless debugging
- verify
Connect via networkin Xcode Devices and Simulators
-
Some installs succeed even if launch fails because the phone was locked; in that case, open the app manually on the device
Validated current IDs:
- main app:
br.com.rslabs.openclient - Live Activity extension:
br.com.rslabs.openclient.activity
Local signing setup now uses:
- shared repo spec:
project.yml - ignored local team override:
project.local.yml
Current local include pattern:
cp project.local.example.yml project.local.yml
INCLUDE_PROJECT_LOCAL_YAML=1 xcodegen generateCurrent entitlements/capabilities in use:
OpenCodeIOSClient/OpenCodeIOSClient.entitlementsOpenCodeChatActivityExtension/OpenCodeChatActivityExtension.entitlements- shared keychain access group:
$(AppIdentifierPrefix)br.com.rslabs.openclient.sharingd
Important App ID guidance:
- use explicit App IDs, not wildcard IDs
- create separate explicit IDs for app and extension targets
- extension IDs are not separate App Store apps
Current release/security posture:
- server passwords are stored in Keychain via
OpenCodeShared/OpenCodeServerPasswordStore.swift - recent server metadata is stored separately from secrets
fastlane/.envis ignored by git and is the local place for App Store Connect credentialsproject.local.ymlis ignored by git and is the local place for personal signing overrides
Current ASC env vars used locally:
APP_STORE_CONNECT_API_KEY_IDAPP_STORE_CONNECT_ISSUER_ID- one of:
APP_STORE_CONNECT_API_KEY_CONTENTAPP_STORE_CONNECT_API_KEY_PATH
Fastlane was made tolerant of both being set locally, but it prefers APP_STORE_CONNECT_API_KEY_CONTENT if both exist.
Validated screenshot pipeline:
- screenshot mode is seeded in-app, not backend-driven
- launch scene env var:
OPENCLIENT_SCREENSHOT_SCENE - screenshot UI test:
OpenCodeIOSClientUITests.testAppStoreScreenshots() - screenshot-only scheme:
OpenCodeIOSClientScreenshots - screenshot output folder:
fastlane/screenshots/en_US/
Current screenshot scenes:
connectionrecent-serversprojectssessionschatpermissionquestion
Current validated capture devices:
iPhone 13 Pro Max(1284 × 2778)iPad Pro 13-inch (M5)
Current one-command screenshot flow:
fastlane ios screenshotsImportant note:
- the repo no longer depends on
fastlane snapshot's helper flow for screenshots - the
screenshotslane runs deterministicxcodebuild testper simulator and writes PNGs directly intofastlane/screenshots/
Validated lanes:
fastlane ios buildfastlane ios archivefastlane ios metadata_checkfastlane ios metadatafastlane ios betafastlane ios screenshots
Important fastlane/version quirks discovered:
- this installed fastlane version does not support
deliver(download_metadata: true)through the action API - to pull live metadata, use the CLI subcommand pattern instead:
fastlane deliver download_metadata ...precheckwith API keys must disable IAP checks for this app:include_in_app_purchases: false
Current metadata/ASC validation status:
fastlane ios metadata_checkpassesfastlane ios betasuccessfully uploaded the first TestFlight build
Current App Store metadata lives in:
fastlane/metadata/
Current marketing/privacy site lives in:
docs/index.htmldocs/privacy/index.html
Validated first-TestFlight blockers and fixes:
- preview-only Swift files must be fully wrapped in
#if DEBUGor Release archive builds fail - automatic provisioning updates were needed during export for app + Live Activity extension
- App Store upload rejected the app until
UISupportedInterfaceOrientationsandUISupportedInterfaceOrientations~ipadwere added to the generated plist
Current upload/export behavior in fastlane:
- archive/export uses automatic signing
- export allows provisioning updates
- TestFlight upload uses
uses_non_exempt_encryption: false
Current binary compliance posture:
ITSAppUsesNonExemptEncryption: falseis set in the generated app plist
What is now automated well:
- metadata validation via
fastlane ios metadata_check - metadata upload via
fastlane ios metadata - TestFlight upload via
fastlane ios beta - deterministic screenshot generation in-repo
What still remains manual in ASC:
- App Privacy answers
- age rating
- some reviewer notes / review info
- any first-time store UI fields Apple does not expose cleanly via fastlane
Important nuance:
- installed app name can remain
OpenClient - App Store listing name must still be unique across App Store Connect
Treat on-device testing as the real source of truth for:
- streaming feel
- keyboard behavior
- split navigation behavior on compact layouts
- permission/question/todo presentation
- glass styling and motion
The refactor is underway but not complete. Remaining priorities:
- Continue moving event mutation out of
AppViewModelinto reducer/store helpers - Reduce or remove remaining fallback/live-refresh polling logic where upstream event flow is sufficient
- Make state ownership more explicit by directory/session, closer to upstream
global-sync+sync - Keep UI polish secondary to architectural consistency with upstream behavior