Adds privacy-safe aggregate product intelligence, reviewed/versioned website assets, and a dispatch-only rollout until the dedicated Sentry token is verified. Independent review blockers were fixed in 8bc47e4; app checks, website production build, Android CI, and iOS CI are green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
256 lines
16 KiB
Markdown
256 lines
16 KiB
Markdown
# OpenCode Mobile — Technical Design Document (TDD)
|
|
|
|
> Audience: engineers working on the app. For product-level context and user
|
|
> stories, see [`prd.md`](./prd.md).
|
|
|
|
## 1. Stack
|
|
|
|
| Layer | Choice | Notes |
|
|
| ----- | ------ | ----- |
|
|
| Runtime | React Native 0.81 + Hermes | New architecture / Fabric on by default via Expo SDK 54 |
|
|
| Framework | Expo SDK 54 + Expo Router 6 | File-based routing under `app/`; typed routes enabled |
|
|
| State | Zustand 5 | Plain stores, no provider tree. Avoids redux boilerplate. |
|
|
| Server data | TanStack Query 5 (cache) + custom `fetch`-based SDK | SSE is hand-rolled (RN's `EventSource` is unreliable). |
|
|
| Persistence | `expo-secure-store` (Keychain / Keystore) | All connection URLs + passwords. |
|
|
| Auth | `expo-local-authentication` | Optional biometric gate at startup and per-send. |
|
|
| Notifications | `expo-notifications` | Local + push; tap deep-links into session. |
|
|
| Crash reporting | `@sentry/react-native` 6.x | Opt-in via env DSN; full URL scrubbing in `beforeSend`. |
|
|
|
|
## 2. Repo Layout
|
|
|
|
```
|
|
app/ Expo Router screens (file-based routes)
|
|
├── _layout.tsx Root layout: Sentry init, ErrorBoundary, providers, SSE wiring
|
|
├── (tabs)/ Bottom tabs: sessions, connections, settings
|
|
├── session/[id].tsx Chat screen
|
|
└── connection/ Add / edit connection screens
|
|
|
|
src/
|
|
├── components/
|
|
│ ├── ErrorBoundary.tsx App-wide React error boundary with Share Report fallback
|
|
│ ├── AuthGate.tsx Biometric prompt before app contents render
|
|
│ ├── chat/ Message bubbles, tool call cards, etc.
|
|
│ └── markdown/ react-native-marked wrapper + custom code-block
|
|
├── lib/
|
|
│ ├── sdk.ts HTTP + SSE client for the opencode server API
|
|
│ ├── sentry.ts Sentry wrapper + global JS/promise handlers + scrubbing
|
|
│ ├── diagnostics.ts Active connection probes + crash report builder + share
|
|
│ ├── logbuffer.ts 200-line ring buffer mirroring console output
|
|
│ ├── notifications.ts Notification setup + categories + dedupe
|
|
│ ├── speech.ts Voice input via expo-speech-recognition (experimental; PRD §7 lists voice beyond OS dictation as out-of-scope for v0.x)
|
|
│ └── types.ts Re-exported model/connection types
|
|
└── stores/
|
|
├── auth.ts Biometric state
|
|
├── connections.ts Server list, active client, project metadata
|
|
├── sessions.ts Session list, messages, parts, optimistic sends
|
|
├── events.ts SSE event loop, reconnect, status tracking
|
|
├── settings.ts User preferences (notifications, biometrics)
|
|
└── catalog.ts Models / providers / commands catalog
|
|
|
|
docs/ PRD + TDD (this file)
|
|
scripts/ E2E test rig (LLM-driven CUA Android smoke)
|
|
```
|
|
|
|
## 3. Data Flow
|
|
|
|
```
|
|
+--------------+ HTTP +-----------------+
|
|
| React UI | ---------> | src/lib/sdk.ts |
|
|
+------+-------+ +--------+--------+
|
|
^ |
|
|
| | fetch / SSE
|
|
| v
|
|
+------+-------+ +-----------------+
|
|
| Zustand | <--------- | opencode srv |
|
|
| stores | events +-----------------+
|
|
+--------------+
|
|
```
|
|
|
|
- The UI never calls `fetch` directly. It calls store actions, which call the SDK.
|
|
- SSE is owned by `stores/events.ts`. It dispatches events into `sessions.ts`, `connections.ts` and notification helpers — UI components only read derived state.
|
|
- Optimistic updates (e.g. `sendMessage`) are rolled back when the server's SSE truth disagrees.
|
|
|
|
## 4. Connection Lifecycle
|
|
|
|
1. `loadConnections()` reads stored connections from SecureStore at startup.
|
|
2. Active connection (if any) builds a `Client` via `createClient({ baseUrl, directory, auth })`.
|
|
3. `client.project.current()` and `client.path.get()` fill in project + server-home metadata; failures are non-fatal (server might be offline). `serverHome` is still consumed by the directory switcher for `~` expansion — but the former *session-scoping* use of this metadata (`sessionScope.ts`, which always resolved to the default client) was dead and removed in `472ff8d`.
|
|
4. The `useEffect` in `_layout.tsx` keyed on `client` starts the SSE event loop the moment a client exists, and stops it when the user removes/changes the connection.
|
|
5. **Active diagnostics on failure.** When a connection is added or tested and fails, `src/lib/diagnostics.ts:probeConnection` runs three parallel probes (health, server-root, public-internet) and classifies the cause (`tls-error`, `timeout`, `no-internet`, `server-unreachable`, `health-failed`, `malformed-url`, `unknown`). The result drives both the UI alert and the Sentry capture.
|
|
|
|
## 5. SSE & Reconnect
|
|
|
|
Implemented in `src/stores/events.ts` (`connect`):
|
|
|
|
- `AbortController` per connection attempt.
|
|
- Async iteration over `client.global.events(signal)`.
|
|
- A **stable-connection timer** (`STABLE_CONNECTION_MS = 10s`) resets the reconnect attempt counter once the stream has been alive long enough — this prevents a healthy stream from accumulating false-positive attempt history.
|
|
- Backoff: `[1s, 2s, 4s, 8s, 15s]` with ±25 % jitter, capped at 15 s.
|
|
- After `PROLONGED_DISCONNECT_MS = 30s` of being down, a notification fires once (deduped with a 60 s cooldown).
|
|
- Disconnect clears all in-flight sessionStatus / statusText / permissions / questions — SSE is the source of truth, never local cache.
|
|
|
|
## 6. Error Handling & Crash Reporting
|
|
|
|
We treat errors at three layers and route them differently:
|
|
|
|
| Layer | Mechanism | Sent to Sentry? |
|
|
| ----- | --------- | --------------- |
|
|
| Expected operational errors (timeout, biometric cancel, network blip) | Caught locally, stored in store `error` field, surfaced as `Alert` / inline UI | **No** — keeps signal high |
|
|
| Connection failures (specifically connect/test/add) | `probeConnection()` → `captureDiagnostic()` with classification + probe context | **Yes**, always, when DSN present |
|
|
| Unexpected crashes | Global handlers + React `ErrorBoundary` | **Yes**, always, when DSN present |
|
|
|
|
### 6.1 Sentry init (`src/lib/sentry.ts`)
|
|
|
|
- DSN comes from `EXPO_PUBLIC_SENTRY_DSN`; if missing, init is a no-op and a breadcrumb-free build is shipped.
|
|
- `release` and `dist` are set from `app.json.expo.version` so Sentry can correlate stack traces to source-map artifacts.
|
|
- `tracesSampleRate: 0` — performance tracing intentionally off.
|
|
- `enableNative: true`, `enableNativeCrashHandling: true` — native (Android NDK / iOS Mach) crashes are captured.
|
|
- `maxBreadcrumbs: 100`.
|
|
- `beforeSend` and `beforeBreadcrumb` run every outgoing event/breadcrumb through `scrubEvent` / `scrubString` / `scrubObject`. These strip basic-auth (`//user:pw@`) and known query-secret keys (`token`, `access_token`, `api_key`, `key`, `password`, `pwd`, `auth`) from any URL anywhere in the payload (request URL, exception value, breadcrumb data).
|
|
|
|
### 6.2 Global handlers
|
|
|
|
Even though `@sentry/react-native` wires its own `ReactNativeErrorHandlers` by default, we install our own thin handlers on top so:
|
|
|
|
1. The 200-line in-memory log buffer (`logbuffer.ts`) always sees the crash — so the *offline* "Share Report" path includes the stack trace even when telemetry is disabled.
|
|
2. Telemetry-disabled builds still leave a forensic trail.
|
|
|
|
Handlers wrap (not replace) the previous handler:
|
|
|
|
- `ErrorUtils.setGlobalHandler` — captures uncaught JS exceptions from the RN bridge. Tagged `crash.source=js-global`, `crash.fatal=true|false`.
|
|
- `globalThis.onunhandledrejection` — captures unhandled promise rejections. Tagged `crash.source=promise-rejection`.
|
|
|
|
### 6.3 React Error Boundary (`src/components/ErrorBoundary.tsx`)
|
|
|
|
A class component because `getDerivedStateFromError` / `componentDidCatch` have no hook equivalent. It:
|
|
|
|
- Catches render-phase exceptions anywhere in the tree.
|
|
- Calls `captureException(err, { level: 'fatal', tags: { 'crash.source': 'react-boundary' }, extra: { componentStack } })`.
|
|
- Renders a dark-themed recovery screen with the error message, top 6 stack frames, top 6 component-stack frames, and two buttons:
|
|
- **Share Report** → `buildCrashReport(err, 'react-boundary')` → `shareReport()` (clipboard + native share sheet, fully offline).
|
|
- **Try Again** → resets boundary state, remounting children.
|
|
- Wraps the entire app inside `_layout.tsx`, outside `GestureHandlerRootView`. `Sentry.wrap(RootLayout)` remains as a second layer of safety net but is rarely the visible one.
|
|
|
|
### 6.4 Breadcrumbs
|
|
|
|
Selective, high-signal — not on every action. Crash reports need *just enough* context to reconstruct what the user was doing:
|
|
|
|
| Site | Category | Message |
|
|
| ---- | -------- | ------- |
|
|
| `_layout.tsx` initial load | `app.lifecycle` | `app started` |
|
|
| `connections.setActiveConnection` | `connection` | `active connection set: <type>` / `…cleared` |
|
|
| `events.connect` | `sse` | `connecting` |
|
|
| `events.scheduleReconnect` | `sse` (warning) | `reconnect scheduled` (attempt, delay, reason) |
|
|
| `events.disconnect` | `sse` | `disconnected` |
|
|
| `sessions.selectSession` | `session` | `select` (sessionID, hasDirectory) |
|
|
|
|
Adding more breadcrumbs is encouraged when triaging a real bug — just keep them out of hot loops.
|
|
|
|
### 6.5 Diagnostic report (`src/lib/diagnostics.ts`)
|
|
|
|
A `DiagnosticReport` is the canonical shape both connection-failure flows and crash flows produce. The same `shareReport()` function copies it to the clipboard and opens the native share sheet, so users see one consistent UI regardless of error source.
|
|
|
|
`formatReport()` includes: classification, summary, target URL, per-probe results, device info, **and the full log-buffer dump**. That last bit is what makes user-shared reports actionable: we get a 200-line trace of what was happening immediately before the failure.
|
|
|
|
### 6.6 URL / secret scrubbing
|
|
|
|
Single source of truth: `scrubUrl(url)` in `sentry.ts`. Applied:
|
|
|
|
- in `captureDiagnostic` before attaching the URL to the Sentry context;
|
|
- in `beforeSend` to recurse over every `event.request.url`, `event.message`, every `exception.value`, every breadcrumb;
|
|
- in `beforeBreadcrumb` for breadcrumb data added between events.
|
|
|
|
A test for this would feed a basic-auth URL into a fake event and assert the scrubbed output. (Not yet written — TODO.)
|
|
|
|
## 7. Versioning & Releases
|
|
|
|
- Single source of truth for the human-facing version is `app.json` → `expo.version` (e.g. `0.4.6`). Since v0.4.6 the `package.json` `version` is kept in sync with it (both are bumped together on release); earlier builds left `package.json` untouched.
|
|
- Git tag `v<version>` triggers `.github/workflows/build.yml` which builds an APK and creates a GitHub Release.
|
|
- Sentry `release` is set to `opencode-mobile@<version>` so source maps (uploaded by `sentry-cli` during build) line up with reported stack frames.
|
|
|
|
## 8. Style Guide (from AGENTS.md)
|
|
|
|
- Prefer `const` over `let`.
|
|
- Early returns over `else`.
|
|
- Single-word variable names where reasonable.
|
|
- Avoid `any` — use `unknown` and narrow, or a defined type.
|
|
- Avoid `try/catch` where you can use `.catch` at the call site; reserve it for boundary points (init, fetch wrappers, event loops).
|
|
|
|
## 9. Known Gaps / Tech Debt
|
|
|
|
- **No automated tests for the error pipeline.** Unit tests for `scrubUrl`, `buildCrashReport`, and the global handlers would catch regressions in the privacy guarantees.
|
|
- **No fallback UI for SSE disconnect.** Today the user sees the existing chat with a (small) banner; a more deliberate "Reconnecting…" affordance would help.
|
|
- **Silent `.catch(() => null)` in stores.** Intentional today (these are non-critical fetches), but should be revisited once we have proper severity tiers for breadcrumbs.
|
|
- **PRD analytics.** No usage analytics; only crash telemetry. A future opt-in product-analytics provider could close that loop without compromising the privacy posture.
|
|
|
|
## 10. Continuous Product Intelligence
|
|
|
|
```text
|
|
Maintainer manual dispatch
|
|
|
|
|
v
|
|
scripts/product-intelligence.mjs
|
|
| GitHub REST: releases, traffic, issues, Actions
|
|
| Sentry REST: aggregate unresolved/new issue health
|
|
| (future) Android Publisher: aggregate review and Play signals
|
|
|
|
|
v
|
|
sanitized Markdown report + JSON evidence
|
|
|
|
|
v
|
|
Actions summary + artifact per UTC day
|
|
|
|
|
v
|
|
material threshold -> deduplicated GitHub issue -> reviewed PR
|
|
```
|
|
|
|
### Components
|
|
|
|
| Component | Location | Purpose |
|
|
| --- | --- | --- |
|
|
| Product-intelligence workflow | `.github/workflows/product-intelligence.yml` | Starts with maintainer dispatch only. Enable its cron after the dedicated Sentry token passes a production run. Uses `actions: read`, `contents: read`, and `issues: write`. |
|
|
| Collector | `scripts/product-intelligence.mjs` | Validates sources, collects aggregate signals, renders a sanitized report, and fails visibly when a source is unavailable. |
|
|
| Material issue upsert | Workflow `actions/github-script` | Locates a stable signal fingerprint and creates or updates an issue only after a material threshold. |
|
|
| Sentry privacy implementation | `src/lib/sentry.ts`, `src/lib/scrub.ts` | Separate P1 prerequisite: remove user-controlled server data from events, breadcrumbs, tags, contexts, and span names before an event leaves device. |
|
|
| Screenshot source of truth | `distribution/reference-screenshots/play-v1/manifest.json` | Records Play asset provenance, checksum, dimensions, and page placement. |
|
|
| Vercel website | `website/` | Versioned Next.js source deployed to existing `opencode-mobile-site` project. |
|
|
|
|
### Data and Privacy Boundaries
|
|
|
|
- The initial collector contains numeric aggregates only. It must not publish
|
|
Sentry issue titles, exception text, culprits, request URL, device metadata,
|
|
raw review content, author names, or user-generated text.
|
|
- The material-issue upsert independently reconstructs its body from an
|
|
allowlisted UTC date and allowlisted signal names. It rejects malformed
|
|
report data and never uses collector-provided Markdown, URLs, or Sentry
|
|
response fields.
|
|
- A missing credential or failed request is reported as unavailable and exits
|
|
non-zero; it must never become a success-shaped zero.
|
|
- The first report supports only GitHub and Sentry aggregates. Every other
|
|
metric-contract row is rendered as `deferred` with its source/access reason.
|
|
- The first delivery introduces no new analytics SDK or user identifier.
|
|
- Sentry remains explicitly opt-in. The consent UI, Settings UI, privacy
|
|
policy, and runtime scrubber must agree on what leaves the device.
|
|
- Future activation and retention metrics require a separate product decision
|
|
that updates consent language and Play Data Safety before code ships.
|
|
- The `SENTRY_PRODUCT_INTELLIGENCE_TOKEN` repository secret is a read-only,
|
|
single-project Sentry token with only `project:read`, `event:read`, and
|
|
`org:read` scopes; it is distinct from release-upload credentials. Any
|
|
future Android Publisher credential is read-only and limited to
|
|
reporting/reviews.
|
|
|
|
### Rollout
|
|
|
|
1. Provision the dedicated read-only Sentry repository secret, then manually
|
|
run the daily workflow against production credentials and verify a
|
|
sanitized Actions summary and artifact for its UTC date.
|
|
2. Enable the cron, then observe one scheduled run and verify it creates a new UTC-day artifact but
|
|
no public issue unless a documented material threshold is crossed.
|
|
Back-to-back manual and scheduled runs must update at most one open
|
|
material-signal issue.
|
|
3. Merge the Sentry privacy boundary before exposing Sentry detail links.
|
|
4. Import and manifest Play screenshots, deploy versioned web source, and
|
|
verify every screenshot loads at the public domain.
|
|
5. Enable Android Publisher review/Play data collection only after
|
|
least-privilege credentials and real-data validation are complete.
|