AGE-110: 64% of 30d-active users sit on v0.4.10 and 0.2% on the newest
build, so a client-side fix (the AGE-105 Sentry noise gate) reaches almost
nobody. Play hygiene fixes one channel; the direct APK, the self-hosted
F-Droid repo and third-party mirrors have no update mechanism at all — a
device installed from a downloaded APK has literally no way to learn a newer
version exists.
- src/lib/update-check-policy.ts: pure, node --test'able decision logic —
numeric version compare (a string compare puts 0.4.10 BEFORE 0.4.9, i.e.
it would have told the largest stale cohort it was current), a 24h check
throttle that survives a backwards clock, per-version dismissal, and a
cached last-known-latest so the affordance survives between checks
- src/lib/update-check.ts: Android-only runtime wiring. One unauthenticated
GET per 24h to the GitHub releases API (every non-Play channel is
downstream of a GitHub release; expo-updates cannot replace a native
binary, which is what this cohort needs). Never throws.
- UpdateBanner on the sessions list: one dismissible strip, no modal.
"Not now" sticks for that version only.
- Settings "Version" row showed a hard-coded "1.0.0" for every build ever
shipped. It now shows the real version, plus "0.4.10 -> 0.4.14" when an
update exists (ignoreDismissed: dismissal silences the banner, not the
place a user goes to check).
- en/zh-Hans strings, catalog parity kept.
Tests: 19 new cases in update-check-policy.test.ts; full suite 300 pass,
tsc --noEmit clean.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* tools(sentry): add org-wide volume report and fix the metric we measure on
The AGE-105 gate is a measured number, so it needs a repeatable query. It also
needed a correction: `accepted` is the wrong headline. The org is over its error
quota, so Sentry rejects nearly everything and `accepted` reads ~0 for every
project - a blown org and a fixed one look identical on that column. The demand
metric is `submitted` = accepted + rate_limited.
scripts/sentry-volume-report.mjs takes named --window ranges and prints
per-project submitted / accepted / rate_limited / client_discard plus the
per-hour and projected per-month rate, so before/after comparisons run the exact
same query instead of being re-derived by hand each time.
Records the pre-rollout baseline in docs/analytics.md: opencode-mobile at
4.71/h (3,441/mo), 87% of the org's post-box-bot demand, from two windows that
agree to within 0.2%.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* test(sentry): pin the noise gate against 90d of real production events
The gate's unit tests prove it behaves as specified. Nothing proved the spec
was aimed at the right targets. Replaying the actual 90d census of the
opencode-mobile Sentry project (648 events, 11 issues) through the gate's own
precedence shows 96.9% hard-dropped as transport noise and every observed crash
class (OOM, ANR, IllegalStateException) still allowlisted -> ~87 events/month
against a 1,500/month target.
Also records two findings from measuring the org directly:
* The error quota resets on the 4th. The 5,000-event month opened 2026-08-04
and was spent by 08-08; the org has accepted zero errors since. 2026-09-04 is
the date the gate has to hold by, and it is why 'submitted' is the metric.
* Server-side levers are unavailable on this plan. A per-key rate limit PUT
returns HTTP 200 and silently discards the value (verified for three window
sizes), custom inbound filters are absent, spike protection 403s. The client
gate is the only control that exists, so its coverage is the whole margin.
Refs AGE-105
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* tools(sentry): split client_discard by reason so gate drops aren't confused with quota backoff
Raw client_discard cannot show whether the noise gate works. Today 100% of
opencode-mobile's client_discard is ratelimit_backoff -- the SDK backing off a
429 because the ORG is over quota -- which rises when things get WORSE. Gate
drops land in a different reason: @sentry/core records before_send when
beforeSend returns null.
- stats_v2 now groups by reason as well as project/outcome
- the before_send vs ratelimit_backoff split always prints; --by-reason adds
the full per-project reason table
- before_send > 0 is install-share-independent, so it proves the gate is live
on real devices days before a monthly rate can bend
- documents that release-level segmentation is impossible while over quota:
rate_limited events are never stored, so release tags stop (last value
0.4.12, 2026-08-08). Version share comes from Play, not Sentry.
* ci(sentry): block a Play release whose bundle lost the noise gate
The AGE-105 quota fix is entirely client-side (every server-side lever on
this plan is dead), so the gate being *in the shipped binary* is the whole
safety margin. That is also the one thing Sentry cannot tell us: while the
org is over quota nothing is stored, release tags stop dead at 0.4.12, and
a release:0.4.14 query returns empty in a way that reads like success.
Grep the Hermes bundle inside the AAB instead, before the Play upload step:
the gate's reason codes, the transport drop-list regex, the
noise.dropped_since_last tag only applyNoiseGate() writes, and a baked-in
DSN (a release built without EXPO_PUBLIC_SENTRY_DSN makes Sentry a silent
no-op). Verified to discriminate on real artifacts - the v0.4.14 build now
on Play production passes, pre-gate v0.4.13 fails all six markers.
Also records the rejected alternative: persisting gate state across cold
starts pays off only under ~94 active devices (2,633 session envelopes/7d
vs a 6h cooldown), and the install base is above that.
---------
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
AGE-107. The 498 `API Error: 401` events from one device were not a client
token-refresh loop. Sentry breadcrumbs on the surviving events show a `touch`
event immediately before every capture, at irregular human-paced intervals
(87s, 199s, 69s, 5s, 61s, 66s) — a person re-tapping Connect, not a backoff
timer. The app's automated loops were already correct: events.ts terminates
the SSE reconnect loop on ApiAuthError (issue #76).
What actually drove it: in v0.4.4 the connection probe counted any HTTP
response as a successful health check, so a 401 was classified `ok` and shown
to the user as "Health endpoint responded — connection actually works now"
while their password was wrong. The user retried for two months. `requireOk`
(#114, v0.4.8) stopped the false success, but 401 then fell into the generic
`health-failed` bucket — "Likely wrong path, auth, or an old server version" —
which still doesn't tell anyone to fix their password.
- New `auth-failed` classification: a 401/403 from /global/health means the
server is up and reachable and rejected the credentials. Its summary names
the status, points at the password and OPENCODE_SERVER_USERNAME, and says
the server is fine. It flows straight into the existing failure Alert on
both the add and edit connection screens — which is where the password
field is, i.e. the re-auth prompt.
- It short-circuits before the root/internet probes can downgrade it: a 401
already proves the server answered.
- `health-failed` copy no longer blames auth.
- `connect auth-failed` joins the noise-gate drop-list. A wrong password is
user config, unactionable server-side, already visible in the UI and
already trended in PostHog as connection_failed{error_class:"unauthorized"}.
`health-failed` and `tls-error` still report.
Tests: 6 new (401/403 -> auth-failed, message content, root-unreachable does
not override, 404/500/502 stay health-failed, health-failed copy drops "auth",
noise gate drops `connect auth-failed` but not a raw `API Error: 401`).
263 pass, tsc --noEmit clean.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
opencode-mobile is now the org's #1 Sentry volume source (~4,500 events/mo
against a 3,500/mo org quota, AGE-105). ~1,100 of those events are three
non-defects: `connect timeout` (462), `connect server-unreachable` (157), and
one device's `API Error: 401` token-refresh loop firing 498 times.
Adds a pure, unit-tested noise gate (src/lib/sentry-noise.ts) wired into
`beforeSend`, applying three layers cheapest-first:
1. Always-send allowlist — OOM/ANR/native/fatal crash classes bypass every
limit. Quota is worthless if it silences real crashes.
2. Transport drop-list — hard drop for client-side network conditions. Hard,
not sampled: the gate runs per-install, so "1 per device per day" would
multiply by the install base straight back into thousands per month.
3. Dedup + rate cap — 6h per-fingerprint cooldown, ≤6 new fingerprints/h,
≤10 events/h, mirroring the openclaw-box-bot shim (AGE-55).
Nothing is lost by the transport drop: those failures are already user-visible
as connection UI and already trended, PII-free, as the PostHog
`connection_failed{error_class}` event. captureDiagnostic() also short-circuits
for those classifications so the event is never even built. Drops are auditable
— the count since the last delivered event rides along as a
`noise.dropped_since_last` tag.
Replaying the observed 1,126-event hour through the gate yields 5 delivered
events (1 auth report + 4 real OOMs).
Tests: 18 new, 257 total passing; tsc --noEmit clean.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* chore(release): v0.4.13 (versionCode 40) — waitlist retry queue reaches users
Ships 2f81d34 (#165): failed waitlist signups are persisted on-device and
retried on app foreground instead of silently falling back to mailto.
Until this Play release, no user is running that fix.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(waitlist): stamp the app version into the mailto escape hatch
AGE-100 asks for the post-release mailto count "split by app version where the
mail body allows it". It did not allow it: the body was "Sign me up!\n\nEmail: x"
and nothing else, so a mail from an unreachable pre-v0.4.8 sideload is byte-identical
to one from a current build whose retry queue leaked. Those two readings have
opposite meanings — the first is the known permanent cohort, the second is a defect.
Now the escape hatch appends "App: OpenCode Mobile v<version>" (app.json, same
source Sentry uses). Absence of the line == pre-v0.4.13 build. waitlist.ts stays
free of react-native/JSON imports; the screen injects the version.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
A signup that hit a network error, the 8s timeout or a 5xx was handed straight
to a `mailto:` composer. That path is lossy by design: it only works if the user
actually presses send, and if we keep reconciling the support inbox into Brevo
list 4 forever (AGE-61's hourly job). 20 of 21 signups were lost that way before
that reconciler existed, and Play's active base is ~100% on v0.4.10+ — so this
was current builds leaking, not just the ~436 stale sideloads.
Now:
- Failed-but-retryable signups are persisted on-device
(`opencode.waitlist.pending.v1`, AsyncStorage) and retried on every app
foreground (`app/_layout.tsx`) and on the Add Connection screen mount.
- 4xx stays non-retryable: the server will never accept that address, so we ask
the user to fix it instead of queueing garbage forever.
- `mailto:` is now only ever opened by an explicit user tap ("Still not working?
Email us instead"), shown after 3 failed attempts, or offered in an alert when
device storage itself refuses the write — never as the silent default.
- The UI tells the truth: "Saved on this device — we'll finish signing you up as
soon as you're back online" instead of implying it was sent.
- `WaitlistResult.fallback` -> `retryable`, `shouldFallbackToMailto` ->
`isRetryableFailure`: the decision is about retry, not about mail.
Queue policy: dedupe by email, cap 5 entries, 30-day TTL, corrupt/foreign JSON
is discarded rather than replayed. Storage and the clock are injected so the
whole thing runs under `node --test` (16 new tests, incl. the acceptance case:
offline signup -> queued -> reconnect -> reaches the server, no mail client).
Also commits the AGE-61 measurement artifacts that were only ever local
(`distribution/waitlist-signup-path-coverage.md`, `scripts/play-version-share.mjs`)
and updates the doc's "current builds still leak" section, which this fixes.
Refs AGE-87, AGE-61.
Co-authored-by: engineer <engineer@macbookpro.lan>
An unsolicited scanner reported CRITICAL "LLM output written to a
persistent memory store" findings against src/lib/notifications.ts:145,
src/lib/sdk.ts:392 and src/lib/session-grouping.ts:24. All three are
false positives: the cited lines are an in-memory notification dedupe
Map, a URLSearchParams limit param, and a bucket push inside a pure
grouping helper. The app persists nothing model-derived — sessions and
messages live on the server and are held in memory by the stores.
Two gates so that stays true and so the one class of report that WAS
real for us (credentials in git history) gets caught before a push:
- security-scan.yml: gitleaks on push/PR to main, full history fetch.
- persisted-keys.test.ts: enumerates every SecureStore write by key.
A new persistence sink fails the suite until someone adds the key
with a note saying what it holds — which is the moment to notice if
it's model output rather than user config. Verified it trips by
adding a throwaway "cache the assistant reply" write.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Root cause: selectSession() re-runs on every navigation focus (#121's
resync), forcing isLoading back to true even when re-selecting the
session already shown on screen. That hides the whole conversation
(messages + composer) behind a spinner for as long as the redundant
GET takes -- while live SSE message/part updates keep flowing to the
store the entire time, just invisible behind the spinner. If that
GET is slow or stalls, the screen looks permanently "loading"; leaving
and re-entering only "fixes" it because it's a fresh retry, not
because anything was actually resolved.
Fix: only force isLoading=true for a genuinely cold load (no session
shown yet, or switching to a different one) via isColdSessionLoad().
A same-session re-focus refreshes in the background without hiding
existing (and live-updating) content. As a second safety net, any
live message.updated/message.part.updated/session.updated event for
the active session now clears isLoading unconditionally via
isLiveEventForSession() -- proof-of-life that unsticks the spinner
even if the GET itself never resolves.
Both are pure, unit-tested in src/lib/session-load-reconcile.ts.
Co-authored-by: test <test@test.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
session.list() now fetches GET /experimental/session (all sessions across
every directory) and falls back to the legacy directory-scoped GET /session
only on 404 (older servers). A directory-less /session is directory-scoped and
returns [] when the active dir has no sessions, so the Recent Sessions list was
empty unless the user first picked a folder.
Global shaping (roots filter, title search, sort by time.updated desc, limit)
moved to a pure, unit-tested src/lib/session-list.ts (no expo/fetch import) with
10 node --test cases.
Co-authored-by: dzianisv <engineer@gray-knight-m1.local>
Four bugs from a review of session creation, the sessions list, and settings
(lower-severity than the core-path hunts — the core is now well-hardened):
1. Double-tap on the new-session FAB / 'Use this folder' created duplicate
sessions (isCreating state lags a render). Added a synchronous re-entrancy
ref guard.
2. 'Require biometric for messages' got stuck ON and enforced with no UI escape
after turning off the parent 'Require biometric to open' toggle (the child
switch is then disabled). authenticateForMessage now also gates on the parent.
3. Session-create failure on the default path silently closed the modal with no
feedback (only the dir path alerted). Both paths now alert; message made generic.
4. Recent-directories got duplicate entries ('/x' vs '/x/') and a mismatched
'current directory' highlight. switchDirectory/addRecentDirectory now
stripTrailingSlash.
typecheck clean, 199 tests, i18n parity.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- MessageBubble: memo comparator only checked the last part's `.text`,
which is always undefined for tool parts, so tool-state/token/cost
updates never re-rendered when a tool part was last. Replace with a
full reference-equality sweep over message + all parts (the store
always replaces changed refs, so this catches every real change).
- DiffView: computeDiff's O(a.length*b.length) LCS table was unbounded,
risking OOM/ANR on large diffs, and the rendered line list was
unbounded too. Add a size-guarded fallback (simple truncated
remove/add diff) and cap the normal path's rendered lines, both with
a truncation marker. Extracted computeDiff into a plain
diff-compute.ts module (mirrors src/lib/scroll-config.ts) so it's
unit-testable with node:test, which can't render .tsx components.
- DiffView: normalize line endings (\r?\n) before diffing so a CRLF
vs LF mismatch doesn't show a whole file as changed.
- Markdown: the module-scope singleton CustomRenderer's github-slugger
never reset, so useMarkdown's keys climbed on every streamed token,
remounting the whole subtree. Scope the renderer per `children` via
useMemo instead.
- Markdown: theme objects used heading1/heading2/heading3/listItem,
but react-native-marked's MarkedStyles expects h1/h2/h3/li, so the
custom heading/list styling was silently dropped. Rename the keys in
both themes.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1. buildRequestHeaders: UTF-8-encode Basic-auth credentials before btoa()
so non-ASCII usernames/passwords don't throw (Hermes' btoa is Latin1-only
and the throw was an unhandled rejection that hung the connect spinner).
2. diagnostics classify(): check root.ok (server reachable) before
!internet.ok, so a reachable-but-failing server (e.g. wrong auth) is no
longer misdiagnosed as "no internet" just because the public-internet
probe also failed (captive portal, Tailscale-only network, etc).
3. sdk.ts createClient: strip trailing slashes from baseUrl once, so a
trailing-slash URL from Advanced mode / Edit screen doesn't produce a
double slash on every request path.
4. add.tsx / [id].tsx: wrap addConnection/updateConnection in try/catch so
a SecureStore failure after a successful test resets the spinner and
shows an alert instead of hanging forever. Adds
connection.shared.alerts.saveFailedTitle/saveFailedMessage (en + zh-Hans).
5. add.tsx / [id].tsx: build the diagnostics probe's auth with buildAuth()
instead of a hand-rolled expression, so the probe reproduces the real
request's credentials (previously Quick Connect's password-only case
sent no auth to the probe at all).
6. add.tsx handleQuickConnect: stop sending the shared `username` state,
which could carry a stray value typed earlier in Advanced mode and
silently override the "opencode" default after "Back to Quick".
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Seven correctness bugs in the session composer:
- sessions.ts sendMessage: await the prompt submission and rethrow on
failure instead of a fire-and-forget .catch(), so handleSend's existing
restore-draft-and-alert catch actually runs.
- pasteFromClipboard: route pasted images through toJpeg() so they get
the same resize/compress treatment as picked/captured photos.
- pickFromLibrary/pickFromCamera: wrap toJpeg() in try/catch (and switch
to Promise.allSettled for the multi-select batch) so one bad asset
doesn't silently drop the whole batch; surface a new imageFailed alert.
- pickFromLibrary: cap selection at 10 images.
- useSpeech: abort the native recognition session on unmount so the mic
doesn't stay hot after leaving the screen.
- Surface useSpeech's error via Alert, keyed on the error value so it
fires once per distinct error.
- Undo on the revert banner now also clears the composer, since it was
prefilled by the edit flow and could otherwise be sent as a duplicate.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The demo's only exit CTA was 'Connect your own server' — useless for the
majority of installers who have no server (the exact churn/retention segment).
Adds a secondary CTA pointing them to the OpenCode Connect (hosted, no-setup)
waitlist, which is the monetization funnel per the founder strategy. Additive,
reuses the existing waitlist on /connection/add and the demo's exit-tracking;
i18n en+zh in parity.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two issues from a security review of the credential/auth path (the review also
verified the fundamentals are solid — passwords in SecureStore, Sentry/analytics/
Chatwoot all scrub secrets).
1. HIGH: biometric app-lock never re-armed. authenticate() sets isAuthenticated
=true once at cold start and lock() was never called (no AppState listener) —
so 'Require Biometric to Open' was fully bypassable: after one unlock, anyone
with brief physical access could reopen a backgrounded app straight into
session history and connection details for the life of the JS process. Now an
AppState 'background' listener calls lock() when the toggle is on. Fires on
'background' only, so the biometric prompt / app switcher (transient
'inactive') don't cause spurious re-locks.
2. Editing a connection's password did nothing: the edit screen's password field
was never passed to updateConnection, which never wrote PASSWORDS_PREFIX — so
a user rotating a server password silently kept using the old one. updateConnection
now takes an optional password and writes it to SecureStore (blank = keep
existing, since the field loads empty).
typecheck clean, 187/187 tests.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Five correctness bugs from an adversarial review of the notification and
permission/approval paths (each verified against the code):
1. Notifications never worked for most users (HIGH): OS permission was only
requested when a user manually toggled a Settings switch off→on. Since
categories default on, that path never fired, permission stayed
'undetermined', and send() silently no-op'd every notification. Now request
it once on first live connection (in-context). (app/_layout.tsx)
2. Wrong-session data after back-navigation (HIGH): session screen reads a
global store and its resync ran only on mount; the native stack keeps
screens mounted underneath a pushed one, so returning to a session could
show another session's messages and permission prompts — approving the wrong
session's tool call. Re-select on focus via useFocusEffect. (app/session/[id].tsx)
3. 'Task completed' fired on aborted/errored runs (misleading, and a duplicate
push alongside 'Session error'). Gate the notify by !aborted && !errored.
(src/stores/events.ts)
4. Tapping a connection-drop notification (no sessionId) navigated to an empty
'/session/' dead-end. Route to home instead. (app/_layout.tsx)
5. Double-tap on a single-select question sent two replies; the second hit an
already-resolved request and popped a spurious 'Reply failed' alert. One-shot
guard on reply/reject. (src/components/chat/QuestionPrompt.tsx)
Verified but intentionally NOT changed: 'completed' notifications default off
(a defensible anti-spam choice — the app still notifies when the agent needs
input). typecheck clean, 187/187 tests.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two real bugs found in an adversarial review of the core real-time path:
1. Queued message vanishes (high impact): the message.updated handler dropped
EVERY temp- optimistic message when any real message arrived, so sending a
second message while the first was still processing made the second
disappear from the chat until its own event landed ('did my message send?').
Extracted the merge into a tested pure helper (mergeIncomingMessage) that
resolves only the oldest pending temp of the same role.
2. selectSession race: rapidly switching sessions on a flaky network could let
a slow fetch for a previous session overwrite currentSession/messages of the
newer selection. Added a monotonic sequence token; a stale result is
discarded.
Also reviewed but intentionally NOT changed: the SSE-reconnect-on-connection-
switch path (already handled via the [client] effect cleanup + reconnect) and
abortSession leaving 'sending' set on failure (deliberate — the run may still
be live; per its own comment).
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Systematic trace of the activation funnel (store listing -> docs setup
guide -> in-app onboarding -> connect flow) after #113 showed a docs
404 bug had slipped through. Fixes found in this pass:
- README.md and CONTRIBUTING.md still told developers to run
`npm install -g opencode` (missing -ai), the exact 404 #113 fixed
everywhere else. Same package-name fix applied here.
- src/lib/diagnostics.ts: the health probe reported ok:true for any
resolved fetch, including 401/403/404/500 responses. A wrong
password or broken server therefore classified as "Health endpoint
responded - connection actually works now.", contradicting the
error shown right next to it. Health now requires a real 2xx;
root-reachability probe explicitly opts out (requireOk: false) to
keep its existing reachability-only semantics (already encoded in
diagnostics-classify.test.ts).
- docs-site/vs-termux: architecture explainer described
/session/{id}/events and /session/{id}/chat, neither of which
exists; the app actually uses /global/event and
/session/{id}/prompt_async (src/lib/sdk.ts).
- docs-site/features: opencode repo link pointed at
github.com/opencode-ai/opencode instead of github.com/sst/opencode,
the org used everywhere else in the docs.
- Stale "v0.4.3" version strings (README x3, docs-site/download x2)
bumped to v0.4.7, the actual latest shipped release confirmed live
on GitHub Releases and the F-Droid repo index. Fixed the associated
Android min-OS contradiction (7.0 vs 8.0+) to 7.0, matching the live
F-Droid manifest's minSdkVersion 24.
- Dropped stale "GPT-4" model naming on 3 docs-site pages, consistent
with the model-agnostic policy already applied to the Play listing
in #83.
See PR body for the found-not-fixed list (Play Console listing drift,
GitHub Sponsors not enabled, a Cloud-vs-waitlist messaging conflict)
that need a product/human decision rather than a code fix.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two scoped changes for the no-spend growth launch (Growth Launch Kit,
Notion page 3a1ac25eb49f81099cc9f3a4286c8ec4):
1. README.md and distribution/play-listing.md said Google Play was
"coming soon" / internal-testing-only, while distribution/retention-analysis.md
and the live play.google.com listing show it's actually public with 1K+
installs. Fixed the contradiction, added Google Play as a third install
channel, and added an accurate mention of the new offline demo mode
("Try a Demo" — reasoning, grep, diff, permission prompt, ~30s, no server)
matching what app/demo.tsx + src/lib/demo-script.ts actually render.
play-listing.md's stale pre-launch checklists are marked historical
instead of rewritten, so #83's ASO copy/keyword work is untouched.
2. Added the demo funnel's key metric (demo-completion, per the launch
kit) as four consent-gated PostHog events: demo_started,
demo_step_advanced, demo_completed, demo_exited_to_connect. Pure
property-derivation logic lives in src/lib/demo-analytics.ts (no
RN/PostHog imports, unit-tested with node --test, same pattern as
analytics-classify.ts) and is wired into app/demo.tsx's lifecycle.
Updated docs/analytics.md's event table and the privacy policy's event
list (distribution/privacy-policy.md + its two HTML mirrors) per the
repo's "new event requires a policy update" convention.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Installers with no self-hosted opencode server hit a dead end at the
empty Sessions state, contributing to ~0% 7-day retention. Adds a
fully offline, scripted /demo route reusing the real chat components
(MessageBubble, ToolCallCard/DiffView, PermissionPrompt) so new users
can see what opencode does before connecting anything, then funnels
them to Connect / the setup guide.
- src/lib/demo-script.ts: pure, hardcoded Message/Part fixture builder
(no RN/store/network imports) — the isolation guarantee.
- app/demo.tsx: new /demo route rendering the scripted conversation
via useMemo'd local state only; permission reply is local setState,
never sessionClient.permission.reply().
- app/(tabs)/index.tsx: "Try a demo" button added to the no-connection
empty state, placed after the existing add-connection-button so its
position/testID for existing Maestro flows is unchanged.
- .maestro/flows/demo.yaml: new E2E flow covering the empty-state CTA
through conversation, diff expand, permission approve, and the CTA
reaching the real connect form.
- scripts/run-e2e-flows.sh: registers demo in NEWER_FLOWS (non-blocking)
so it actually runs in CI.
- i18n: new sessionsList.empty.tryDemoButton and demo.* keys added to
both en.json and zh-Hans.json (catalog-parity verified).
npm run typecheck: clean. npm test: 175/175 passing.
Co-authored-by: engineer <engineer@macbookpro.lan>
* fix(e2e): fix directory-picker race + markdown accessibility, soften variant-picker SSE assertion
Second iteration against real CI evidence from run 29617520311 (PR #105):
directory-picker still failed after enabling static snapPoints. The mock
server's own request log proved GET /file was still never called, meaning
DirectoryBrowserSheet's onChange never ran enter(). Root cause: the caller
(openBrowser in app/(tabs)/index.tsx) sets startDirectory via setState and
calls sheetRef.current?.expand() synchronously in the same handler. expand()
kicks off a reanimated-driven animation whose onChange fires before React
commits the re-render that would give the child the new startDirectory prop,
so the first onChange(index=0) captured the stale initial `null` and set
wasOpen=true — permanently blocking every later onChange for that open.
Mirrored startDirectory into a ref (updated inline on every render) so
handleSheetChange always reads the latest value regardless of which
render's closure actually fires.
diff-scroll still failed even after removing the nested FlatList — but the
new diagnostic screenshot showed the text WAS visually on screen while
Maestro's accessibility-tree-based assertion still couldn't find it for the
full timeout. That matches a real, still-open React Native Android bug
(facebook/react-native#46999, a reopened regression of #28952's fix):
selectable Text inside a FlatList row doesn't get its selectable/accessible
state applied correctly. react-native-marked's base Renderer hardcodes
`selectable` on every plain text node (text/strong/em/del/heading/codespan).
Overrode those in Markdown.tsx's CustomRenderer to render plain (non-
selectable) Text — code content stays copyable via CodeBlock's own Copy
button.
variant-picker: confirmed the model-selection fix worked completely (chip
appears, opens, selects, label updates) and the flow only fails afterward at
the exact same SSE-streamed-reply limitation documented in
activation-positive.yaml (issue #90 mode B — this CI harness's Android
emulator + Node mock + adb-reverse combination cannot deliver more than the
SSE stream's first chunk). Softened the post-send assertion to match
activation-positive's pattern: verify the optimistic local echo
(chat-bubble-user) instead of waiting on the unrenderable-in-CI reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ci): capture maestro hierarchy dumps in debug artifact upload
actions/upload-artifact excludes dotfiles/dot-directories by default, and
maestro's --debug-output nests the actual UI-hierarchy dump under a hidden
.maestro/tests/<timestamp>/ directory — so every activation-e2e run has been
silently uploading only logcat.txt/probe.txt and dropping the one artifact
most useful for diagnosing flow failures (issue #104). Set
include-hidden-files: true on that upload step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(onboarding): clarify opencode-serve requirement and fail connect tests fast
New users bounce at ~0% 7-day retention because nothing tells them the app
needs a computer running `opencode serve` on the same network/Tailscale, and
a bad IP hangs for the full 30s request timeout before failing.
- Rewrite the no-connection empty state subtitle and add a "How to set up a
server" link to the setup guide (app/(tabs)/index.tsx, src/lib/links.ts).
- Surface the opencode-serve prerequisite as a one-line notice at the top of
the Quick Connect form, above the existing detailed help box
(app/connection/add.tsx).
- Give the interactive connection test (testConnection) its own 12s timeout
via an optional Client.global.health(timeoutMs) parameter, instead of
reusing the general 30s REQUEST_TIMEOUT_MS used for real session traffic
(src/lib/sdk.ts, src/stores/connections.ts).
- Mirror all new/changed strings in the zh-Hans catalog; catalog-parity test
keeps them in sync.
* docs(distribution): add retention analysis motivating first-run fixes
Diagnoses ~0% D7 retention as product-shape (no path to value without a
self-hosted server, no demo mode, store copy sets no expectation). Ranks
fixes and isolates the two owner-only strategic calls (store-copy honesty,
hosted OpenCode Connect).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
* fix(onboarding): drop connect-screen prerequisite notice (kept off-screen the submit button in E2E)
The added notice pushed connect-submit-button below the fold, breaking the
Maestro activation-positive flow (and the other flows sharing the connect
prelude). The empty state already sets the opencode-serve expectation one
screen earlier, so this notice was redundant. Empty-state guidance + guide
link and the fast-fail connect timeout are unaffected and retained.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
---------
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root-caused directory-picker's directory-row-frontend failure: all four
in-app @gorhom/bottom-sheet sheets (DirectoryBrowserSheet, DirectorySwitcher,
ModelPicker, VariantPicker) provide static percentage snapPoints but rely on
v5's enableDynamicSizing default (true), which never resolves without content
wrapped in a size-reporting component — so useAnimatedDetents() permanently
early-exits and the sheets can never actually open. Set
enableDynamicSizing={false} on all four (they already have explicit
snapPoints, so dynamic sizing was never needed).
variant-picker's chip failure was a stale test assumption: src/lib/
model-selection.ts's chooseModelSelection() deliberately returns null for a
fresh session (issue #37/#35 — the provider registry default is unreliable),
so a brand-new session has no model selected and the reasoning-effort chip
has nothing to key off of. Added testIDs (model-chip, model-option-*) and
updated the flow to explicitly pick a model first, matching real usage.
diff-scroll's missing markdown text: switched src/components/markdown/
Markdown.tsx from react-native-marked's FlatList-based default export to its
useMarkdown() hook rendered into a plain View. The chat screen already nests
this inside its own *inverted* FlatList (one row per message) — a nested
VirtualizedList inside an inverted outer list is a known RN footgun where the
inner content can render at zero height instead of just warning. We already
forced scrollEnabled:false + a large initialNumToRender, defeating
virtualization anyway, so rendering the parsed blocks directly loses nothing.
Extended the existing react-native-marked .d.ts shim (added for a React
18/19 ReactNode mismatch) to also declare useMarkdown/useMarkdownHookOptions.
Added diagnostic screenshots to directory-picker.yaml and diff-scroll.yaml
at the previously-failing steps for faster triage if these regress again.
Closes#104.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Extends the i18n infra from #97 (Settings screen only) to the rest of the
app: session chat screen, connection add/edit/list screens, sessions list
(incl. directory grouping from #96), navigation titles, notifications
category metadata, error boundary, telemetry consent modal, auth gate, and
every chat UI component (permission/question prompts, status indicator,
model/variant pickers, directory switcher/browser, reasoning block, tool
call card, session info).
- 244 new keys added to en.json/zh-Hans.json with reviewed, natural
Simplified Chinese (not machine-garbage), keeping key sets identical.
- User content, server URLs, code snippets, log/error-detail text, and
diagnostics-classify.ts (pure dependency-free module feeding Sentry/
support reports) are intentionally left untranslated per scope.
- Interpolation used for counts/names (e.g. reconnect attempt, files
count, connection name in delete confirmations); categoryMeta/
CONNECTION_TYPES switched to labelKey indirection since they're
module-level constants evaluated before i18next is guaranteed ready.
- Added src/lib/i18n/catalog-parity.test.ts (node --test) asserting
en.json/zh-Hans.json expose identical key sets and no empty values,
to catch future locale drift.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Turns the manual QA ask ("verify DiffView + CodeBlock horizontal-scroll
on-device with a populated diff") into two automated layers:
1. Unit (deterministic, runs in `npm test` now): extracted the shared
ScrollView props into src/lib/scroll-config.ts (WIDE_CONTENT_SCROLL_CONFIG)
so DiffView.tsx and CodeBlock.tsx spread the SAME plain object their tests
assert on — no react-native-renderer needed. Added a source-scan
regression test (wide-content-scroll.regression.test.ts) that fails if
either component loses its ScrollView wiring or reintroduces
numberOfLines truncation.
2. E2E (Maestro): .maestro/flows/diff-scroll.yaml opens a session with a
pre-seeded wide edit-diff tool call and a wide fenced code block, then
swipes each horizontal ScrollView left and asserts the off-screen marker
text becomes visible. mock-opencode-server.ts gained a --seed-diff mode
that serves this session via GET /session/:id/message (pre-existing
history), not SSE — issue #90 (a separate SSE-render bug) is being fixed
independently, and this flow must not depend on it landing first. Wired
the new flow + port 4100 into run-e2e-flows.sh and activation-e2e.yml.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The directory browser could only descend from a manually-typed path
since the SDK had no way to enumerate the server's filesystem roots
(multiple drives on Windows, mount points, home dir). Add
file.roots() to sdk.ts (GET /file/roots, added server-side in
dzianisv/opencode#238) and show the results as pinned top-level chips
in DirectoryBrowserSheet that jump straight into that root.
Degrades gracefully: older servers 404 on the new endpoint, which the
SDK turns into null, normalizeRoots() turns into an empty list, and
the browser just shows no chips — manual "Jump to path" entry keeps
working exactly as before.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The app ships PostHog activation-funnel analytics gated behind the same
consent flag as Sentry, but the consent modal, Settings toggle, privacy
policy, and Play Data safety draft only mentioned crash reporting. Fix
the disclosure everywhere:
- TelemetryConsentModal: body + bullets + a11y labels now cover anonymous
usage analytics (PostHog EU) alongside crash reports
- Settings: toggle renamed 'Crash Reports & Usage Analytics', description
names both Sentry and PostHog
- Privacy policy (md + html + live gh-pages mirror): new section 3a with
the full event/property table, PostHog EU destination, anonymous-ID
statement, decline/revoke (drop-on-revoke) semantics; sections 4-7, 9
and the Apple nutrition-label addendum updated for analytics
- play-listing.md: Data safety draft declares App interactions + Device
or other IDs (opt-in, default OFF, shared with PostHog/Sentry)
- docs/playstore.md: Data safety row flipped to re-verify with pointer
to the new design record
- docs/analytics.md: new design record — event schema, consent gating
incl. buffered-event drop on revoke, disclosure surfaces to keep in
sync, verification checklist (all TODO)
- website privacy page metadata mentions analytics opt-in
Closes#63
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds diagnostics-only instrumentation to attribute the Activation E2E
positive flow's "SSE reply never renders" failure (issue #90 mode B)
between (a) expo/fetch not streaming the SSE response on the Android
release APK, vs (b) a mock-side broadcast bug. Does not change app
behavior or fix the root cause — #90 stays open pending the next CI
run's enriched logs.
- tests/fixtures/mock-opencode-server.ts: per-request logging
(method/path/status), per-SSE-connection connect/disconnect logging
with live client count, per-broadcast event-type + client-count
logging, and a 2s SSE heartbeat comment so client-side silence
becomes unambiguous.
- src/lib/sdk.ts global.events(): logs on the first successful
reader.read() that returns data, and when the stream loop ends —
proves/disproves whether expo/fetch ever delivers a byte.
- scripts/run-e2e-flows.sh: the emulator->mock reachability probe used
toybox wget/nc, which don't work reliably on the API-28 image.
Replaced with a probe chain (curl, wget, mksh /dev/tcp, nc, then a
host-side fallback) that writes a clear PASS/FAIL/UNKNOWN verdict to
artifacts/diag/probe.txt without blocking the flow.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test: E2E coverage for directory picker, all-sessions, variant picker
Extend the Maestro suite for the features merged into main today:
DirectoryBrowserSheet's server-folder picker, the directory-less
all-sessions-across-projects list (+ the #46/#48 open-across-project
regression), and VariantPicker's reasoning-effort chip.
- tests/fixtures/mock-opencode-server.ts: GET /file (directory-scoped via
the x-opencode-directory header) with a small fake tree, GET /project
for the "Server Projects" section, POST /session honoring the directory
header, GET /session/:id (needed to open a session from the all-sessions
list), GET /provider variants for VariantPicker, and an optional
--seed-sessions mode that pre-populates two sessions across two
directories. --fail-auth mode is untouched.
- .maestro/flows/directory-picker.yaml, all-sessions.yaml,
variant-picker.yaml: three new flows, run in the same emulator session
as the existing activation flows.
- Additive testIDs on DirectoryBrowserSheet, the "Browse Folders" row,
session list rows, the variant chip, and VariantPicker rows.
- .github/workflows/activation-e2e.yml: two more mock server instances
(4098 seeded, 4099 fresh) and three more maestro test steps.
Verified: tsc --noEmit clean, all 108 existing unit tests pass, every new
mock endpoint curled against its real shape read from the app code, YAML
validated. No Android emulator available locally to run the Maestro flows
themselves.
* test(mock): enforce per-directory session scoping so #46/#48 coverage can fail
Review finding (HIGH): GET /session/:id and /session/:id/message ignored
x-opencode-directory, so all-sessions.yaml could not fail if the directory
threading fix regressed. The mock now mirrors the real server's per-directory
workspace scoping:
- GET /session/:id and GET /session/:id/message 404 unless the request's
x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the stored
session's directory.
- GET /session without ?roots=true is scoped to the request's directory;
loadSessions()'s directory-less roots=true call still returns everything.
- Document the port-4099 shared-state coupling between directory-picker and
variant-picker flows, and why all-sessions.yaml now has teeth (flow comment).
Curl-verified: correct header 200, wrong/no header 404, scoped vs roots
listing, create-then-open paths for all three flows, --fail-auth untouched.
tsc clean, 108/108 unit tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
* fix(e2e): widen connect-handshake wait past client's own 30s timeout
Run 29546383612 (7cbd3a6, first real emulator execution of these flows)
failed on activation-positive.yaml: "Assert that id: connection-status-dot
is visible" timed out after the flow's 20s extendedWaitUntil, right after
tapOn connect-submit-button.
The mock server itself is fast (verified locally: health + project/current
+ path respond in ~30ms total), so this isn't a mock fidelity gap. But
Quick Connect's testConnection()/addConnection() path chains up to 3
fetches (health, then project.current + path.get in parallel), and each
individual fetch is capped by src/lib/sdk.ts REQUEST_TIMEOUT_MS = 30_000 —
strictly longer than the 20s the flow was willing to wait. A first-attempt
emulator-to-host (10.0.2.2) connection that's merely slow to establish,
rather than outright failing, would blow past the test's wait before the
app's own client-side timeout even fires.
Bump the connect -> connection-status-dot / "Connection Failed" waits from
20000 to 40000 across all 5 flows that share this pattern
(activation-positive, activation-negative-401, all-sessions,
directory-picker, variant-picker) so the wait is never shorter than the
code path it's gating on. Assertions are unchanged — still requires the
real dot / real error text, just with a timeout that isn't racing the
client.
Verified locally: typecheck clean, all 108 unit tests pass, YAML parses,
mock server confirmed fast under direct curl. Emulator behavior itself
(whether 40s consistently clears it) is unverified until the next CI run.
* fix(e2e): use adb reverse + 127.0.0.1 instead of 10.0.2.2; capture logcat/maestro debug
Root cause of the activation-e2e failure (connect step timed out, ~0 requests
reaching the mock): the 10.0.2.2 host alias is unreliable under the headless
emulator-runner — the app's http://10.0.2.2:4096/global/health never completed,
so connection-status-dot never rendered.
- run-e2e-flows.sh: single script (fixes cd-per-line fragility) that adb-reverses
each mock port (4096-4099) into the emulator's localhost, runs every flow with
--debug-output, and dumps logcat on exit.
- All flows now connect to 127.0.0.1:<port> (the adb reverse target).
- Upload maestro-debug (UI hierarchy on failure) + logcat as artifacts so future
failures are diagnosable instead of blind.
* fix(e2e): connect via 127.0.0.1:PORT in IP field, stop typing into port input
Root cause of every activation-e2e connect failure (proven by the app's own
logcat diagnostic: '[diag] probe start http://127.0.0.1:40966 ... server
unreachable'): the port field defaults to useState("4096"), and the flow's
eraseText + inputText "4096" raced the controlled number-pad input, leaving
"40966" — nothing listens there, so connect always failed. This was never a
10.0.2.2 / adb reverse issue.
Fix: buildUrl already extracts host:port from the IP field, so enter
127.0.0.1:<port> there and remove the flaky port-field steps entirely.
pastedPort overrides the default port state, so each flow's port is
deterministic (4096 positive / 4097 negative / 4098 all-sessions / 4099
directory+variant).
---------
Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- Add expo-localization, i18next, react-i18next (versions aligned with
Expo 54 / RN 0.81)
- src/lib/i18n/locale-resolve.ts: pure locale-resolution helpers
(system tag -> supported catalog, with en fallback), unit-tested via
node --test with no RN imports
- src/lib/i18n/config.ts: i18next init wired to expo-localization
device detection, en.json + zh-Hans.json catalogs
- Persist a locale preference (system | en | zh-Hans) in the settings
zustand store, applied immediately via i18next.changeLanguage
- Wire I18nextProvider in app/_layout.tsx
- Localize the Settings screen (~28 strings) as the reference pattern
for extracting user-facing strings, with a language picker row and
reviewed Simplified Chinese translations
Other screens (session/[id], connection/*, index, chat components)
are deferred follow-up — issue #68 stays open for that work.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): stop infinite SSE retry on 401/403 and surface auth failures
Root cause (Sentry OPENCODE-MOBILE-1, 309 events / 65 users): auth is static
HTTP Basic and no code path treated 401 specially. The SSE reconnect loop in
events.ts retried on a fixed backoff regardless of cause, so a bad password
spammed Sentry and drained battery forever with zero user feedback.
Advanced-mode connection save also had no pre-flight check and silently
persisted bad credentials as the active connection.
- src/lib/api-error.ts: new pure ApiAuthError/isAuthStatus/isAuthError module
(node --test covered) so 401/403 are distinguishable from other failures.
- src/lib/sdk.ts: request()/events() now throw ApiAuthError for 401/403
instead of a generic Error.
- src/stores/events.ts: the SSE loop stops retrying on an auth error and sets
a new `authError` flag instead of reconnecting forever; other errors keep
the existing backoff. Fires connection_failed (source: sse, error_class:
unauthorized) so it's visible in the existing funnel.
- app/(tabs)/index.tsx: sessions screen shows an "Authentication Failed"
state with a link to the connection edit screen when authError is set.
- app/connection/[id].tsx: saving edited credentials for the active
connection now reconnects SSE immediately instead of requiring an app
restart.
- app/connection/add.tsx: Advanced-mode save now runs the same testConnection
pre-flight as Quick Connect and shows the same "Connection Failed" alert
(with diagnostics/share-report) instead of silently saving bad credentials.
Closes#76
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
* fix(auth): add Retry button on 401 error state, widen ConnectionTestSource
- Authentication Failed screen now offers Retry alongside Check
Credentials, calling events store's connect() directly to restart
the SSE state machine on transient 401s without leaving the app.
- Widen ConnectionTestSource to include 'sse' (events.ts:389's
connection_failed track call) and note the activation funnel only
filters on source=onboarding.
Addresses PR #79 review follow-ups.
---------
Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: edit/revert sent messages via server revert API
Wires the mobile client up to the opencode server's session.revert /
session.unrevert endpoints (the same primitive the desktop TUI uses to
edit the last message). Long-press a user message bubble -> "Edit
message" reverts it server-side and prefills the composer with its
text; a banner offers Undo while the revert is pending (it's only
cleaned up server-side on the next prompt). Degrades gracefully with
an alert on older servers that 404 the /revert route.
Closes#56
* fix(revert): address code review findings on edit/revert message flow
- Confirm before overwriting an in-progress composer draft when editing
a sent message (F2)
- Restore reverted message's file attachments into the composer, not
just its text (F3)
- Distinguish 401/403 from other revert failures with an accurate
"Authentication failed" message instead of a generic one (F4)
- Exclude optimistic "temp-" message IDs from the revert cutoff
comparison so concurrently-sent messages aren't hidden (F9)
---------
Co-authored-by: engineer <engineer@gray-knight-m1.local>
* feat(waitlist): capture OpenCode Connect signups via beta-signup API (closes#87)
The 'OpenCode Connect — Coming Soon' card only opened a raw mailto: link,
so waitlist signups existed solely as loose emails in the support inbox
with no backend capture.
- POST the signup to https://opencode.agentlabs.cc/api/beta-signup
(OpenCodeMobileSite route -> Brevo list) tagged with
source: "opencode-connect-waitlist". The route ignores unknown fields
today, so the tag is forward-compatible.
- Pure payload/validation/fallback logic lives in src/lib/waitlist.ts
(no react-native imports, dependency-injected fetch, AbortController
timeout like diagnostics.ts) with node --test coverage.
- Graceful degradation: transport failures and 5xx fall back to the old
mailto: path so the signup still reaches the inbox; 4xx asks the user
to fix their email. Success shows an inline confirmation state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(waitlist): handle mailto fallback failure, name 502 in fallback test
Review findings: Linking.openURL was fire-and-forget, so a device with
no mail app failed the recovery path silently — await it and alert with
a manual instruction instead. Test title now names 502 (Brevo failure)
as an explicit fallback case.
Refs #87
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(feedback): deliver shared diagnostic reports to Chatwoot support inbox
Wire shareReport() to the Chatwoot public client API
(/public/api/v1/inboxes/{inbox_identifier}) so user-shared diagnostic
reports also reach the OpenCode Mobile Feedback inbox.
- New src/lib/chatwoot.ts: dependency-injected, node-testable client —
anonymous contact -> conversation -> message. Ships only the inbox
identifier (EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER); never an
account api_access_token. Contact source_id persisted via
SecureStore for conversation continuity; stale id recreated on 404.
- Delivery is gated on the same telemetry consent flag as
Sentry/PostHog and is best-effort (share sheet never blocks on it).
- Reports are scrubbed before leaving the device: all URLs and every
occurrence of the target host redacted (new redactHostAndUrls in
scrub.ts).
- CI: pass EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER in build and
Play-publish workflows. Deliberately NOT added to the F-Droid
workflow to avoid widening reproducible-build divergence (#86).
- Consent modal copy discloses support-inbox delivery.
Closes#85
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(feedback): close host-leak gaps in support-report scrubbing
Security review findings on the Chatwoot delivery path:
- Log-buffer lines record server hosts without a scheme, which the
URL regex never matches, and crash reports carry no host of their
own — so bare hostnames could reach the support inbox. Track every
host probed this session and redact them all in the support copy.
- Redact bare IPv4 addresses as a catch-all for hosts never parsed.
- Resolve telemetry consent from SecureStore when a report is shared
before startup finished loading it, instead of silently dropping.
- Move redactHostAndUrls tests to scrub.test.ts alongside the module.
Refs #85
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sessions): load all sessions across projects, not just active directory
Closes#48
Root cause: loadSessions() used connState.client which carries the
active connection's directory as x-opencode-directory header. The server
filters sessions by that directory, so only the current project's sessions
were visible.
Fix: call clientForDirectory(undefined) to get a no-header client.
The server then returns sessions from all projects.
The session row UI already showed a directory badge (shortDir from
session.directory), so no UI change is needed — each session already
displays its project folder name.
* fix(sessions): preserve directory when opening rows
Carry each listed session directory into the route so selection, messages, and follow-up operations use the matching project client.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(#49): improve project picker with recents + server projects
- New Session modal now shows:
- Current project as tappable row (tap to create session immediately)
- Recent Projects section: list of previously used dirs as tappable rows
- Server Projects section: projects known to opencode server (from /project API)
- Manual path input as fallback (unchanged behavior)
- Modal body is now scrollable to handle long lists
- All selection paths call addRecentDirectory to keep recents up to date
- TypeScript clean (pre-existing VariantPicker.tsx error unrelated)
* feat: add reasoning effort (variant) picker to session screen (#47)
- Add VariantPicker bottom sheet component (low/medium/high/auto)
- Add variant state to catalog store, reset on model change
- Pass variant through sendMessage -> sdk.session.prompt()
- Add reasoning chip to toolbar, shown only for models with variants
- Parse model.variants from provider API response in catalog and sdk types
API field: variant in POST /session/:id/prompt_async
Server maps variant -> reasoningEffort via model variant config
* fix(models): preserve reasoning effort across messages
Reset the selected variant only when the provider/model pair actually changes, including catalog reloads and agent-driven model switches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- app_opened now also fires on the consent-grant transition (modal Allow /
Settings toggle), not just cold start with prior consent — the true first
session was emitting nothing and session 2 got mislabeled is_first_open.
trackAppOpened() is guarded once-per-JS-session so revoke->regrant cannot
double-count.
- testConnection() takes a source ('onboarding' | 'edit_test') carried on
connection_attempted/succeeded/failed so the funnel can filter out the
edit screen's repeat-tester noise.
- Aborted runs no longer count: abortedSessions set (in sessions.ts, read by
events.ts which already imports it — no new import cycle), marked after a
successful abort call, cleared on busy, and checked on busy->idle for BOTH
response_received and recordSuccessfulSession().
- Consent revocation now DROPS buffered events instead of flushing them:
PostHog's optOut() only blocks new captures and shutdown() drains the queue
over the network, so ConsentGatedPostHog overrides the public fetch()
transport to answer with a synthetic 200 post-revoke — shutdown clears the
persisted queue and timers with zero bytes leaving the device. Re-grant
calls optIn() to clear the persisted SDK opt-out flag.
- classifyConnectionError extracted to pure analytics-classify.ts with
node --test coverage (same pattern as store-review-policy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
- HIGH: the "Browse Folders..." entry in the New Session RN <Modal> expanded
a sibling BottomSheet, which a native Modal always covers (a
BottomSheetModal through the root portal would be covered too), so the
primary entry point was invisible/untouchable. The modal is now closed
before the sheet expands and restored on cancel via a new onDismiss
callback (restoreNewSessionOnDismiss ref); picking a folder proceeds to
session creation without reopening the modal.
- MEDIUM: parentOf("/") returned "/" so Up at the POSIX root looped forever;
it now returns null at "/", "\" and Windows drive roots alike, disabling
the Up button there.
- LOW: opening the sheet with no known start directory (server home not
loaded yet) showed the previous open's stale entries; it now clears state,
invalidates in-flight loads, and shows an "Enter a path above to start
browsing" empty state. Sheet init also no longer re-runs on snap-point
drags (wasOpen guard).
- Extracted the pure path helpers (stripTrailingSlash/parentOf/nameOf) into
src/lib/path-utils.ts (no RN imports) with node --test coverage for POSIX
root, Windows drive roots, trailing slashes, and backslash paths.
typecheck clean; 97/97 tests pass (16 new).
Adds deterministic end-to-end coverage for first-open -> telemetry consent
-> server URL entry -> connect -> send first message -> receive reply,
targeting the 0%-7-day-retention investigation (GitHub issue #76).
- tests/fixtures/mock-opencode-server.ts: dependency-free HTTP+SSE stub
matching the REAL client protocol (src/lib/sdk.ts) — REST + a single
long-lived GET /global/event SSE stream, no WebSocket. Supports a
--fail-auth mode that 401s every request to exercise the connect-time
auth-failure class.
- .maestro/flows/activation-positive.yaml: consent -> quick connect ->
new session -> send message -> assert streamed reply renders, with a
screenshot at every step (positive-S1..S8).
- .maestro/flows/activation-negative-401.yaml: same setup against the
--fail-auth server, asserts Quick Connect's existing "Connection Failed"
alert is shown (not silently swallowed) and that the connection is not
saved. Flags in comments that Advanced-mode Save (handleAdvancedSave)
still has no testConnection() check and is a known, uncovered gap.
- testID props added (no restructuring) to the screens/components the
flows drive: TelemetryConsentModal, connection/add.tsx, tabs/index.tsx,
session/[id].tsx, MessageBubble.
- .github/workflows/activation-e2e.yml: new CI job — Android emulator via
reactivecircus/android-emulator-runner, builds the debug-signed APK,
starts both mock server instances, runs both Maestro flows, uploads
screenshots via actions/upload-artifact. Kept separate from the existing
vision-driven cua-smoke.yml, which needs a live server + LLM and isn't
suited to tight deterministic regression assertions.
- .gitignore: Maestro takeScreenshot output is never committed.
Verified locally: mock server exercised standalone via curl (health,
project/current, path, session create, SSE event ordering, message
persistence) in both normal and --fail-auth modes; both Maestro flow
files validated as well-formed YAML; tsc --noEmit clean on all changed
files. No lint script exists in this repo (N/A). Full emulator execution
was not run — no Android SDK/emulator available in this environment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
Review findings on the store-review prompt:
1. SessionStatus has no error variant and session.error never touches
sessionStatus, so an errored session still ends busy -> idle and was
counted as a success — potentially burning the once-ever review prompt
on a failed run. Track an erroredSessions set: mark in the
session.error handler, clear when the session goes busy again (new
run) and on disconnect, and skip recordSuccessfulSession() on the
busy -> idle transition if the session errored.
2. ASKED_KEY was persisted only after requestReview() resolved. On iOS
requestReview() can throw (MissingCurrentWindowSceneException while
backgrounded — likely, since sessions often complete in background),
which would retry the prompt on later successes, violating the
"at most once, ever" contract. Persist ASKED_KEY before calling
requestReview(); a failed attempt consumes the one shot.
Users had to type an absolute server path on a phone keyboard to pick a
working directory (#49 "Choose project UIX"), and #57 reports that
only the default-drive project is ever discoverable. #52 already added
recents + client.project.list() as flat pickers, but there was still no
way to browse into subdirectories or discover paths the server hadn't
already indexed as a "project" — the only fallback was manual typing.
The opencode server already exposes a scoped filesystem-listing endpoint
(GET /file, handled in file.ts/handlers/file.ts) that resolves relative
to whatever directory the request is scoped to (header or query param) —
no new server endpoint is needed. Add file.list() to the mobile SDK
client and a new DirectoryBrowserSheet that lists subdirectories one
level at a time (via clientForDirectory(dir) + file.list({path: "."})),
supports "up" navigation, and a manual jump-to-path field. Wire it into
both the "new session" modal and the existing DirectorySwitcher, so
recents/manual entry remain available as a fallback alongside browsing.
Residual gap: there's still no "list available drives" API, so Windows
users with projects on D:, E:, etc. still need to type the drive root
once (it's then remembered via recents) — a full fix for #57 would need
a small server-side addition to enumerate mounted volumes.
Installs are up 615% but 7-day retention is ~0% and we had no analytics SDK
to see where users drop off. Adds a thin PostHog wrapper (src/lib/analytics.ts)
that tracks app_opened, connection_form_submitted, connection_attempted,
connection_succeeded/failed (with a coarse error_class, e.g. the known 401
auth bug), message_sent, and response_received.
PostHog was chosen over Aptabase for its GMS-free JS-only RN SDK (fine for
the F-Droid/no-Firebase build), EU-hosted/self-host option, and generous
free tier. Analytics shares the exact same consent flag as Sentry
(telemetry.ts now gates both) so zero network calls happen without explicit
opt-in.
Requires a new EXPO_PUBLIC_POSTHOG_KEY CI secret (wired into build.yml,
publish-fdroid.yml, publish-play-store.yml, and documented in
publish-app-store.yml alongside the existing Sentry secrets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
Add expo-store-review (SDK 54-matched via `expo install`) and wire a
one-time in-app rating prompt into the SSE busy->idle "session completed"
transition in stores/events.ts — the same signal that already drives the
"Task completed" notification, so it only fires on genuine success, never
on session.error.
State (success count, one-time "asked" flag) persists in expo-secure-store,
mirroring the consent pattern in telemetry.ts. The threshold check is split
into store-review-policy.ts, free of expo imports, so it's unit-testable
with plain `node --test` (same split as buildAuth in auth.ts).
F-Droid/Play-Services-absent safety comes from the library itself:
StoreReview.isAvailableAsync() resolves false there, so requestReview() is
never called and there's no store-URL fallback configured in app.json.