The gate ships inside the app binary, so it only runs on devices that took
v0.4.14. Grading it on a raw event count is a measurement error in both
directions: a still-high number at 30% uptake is the gate WORKING (~70% of
baseline is the model's own prediction), and a dip from a quiet weekend is not
efficacy. The scheduled re-reads on 08-17 / 08-21 / 09-05 would have hit the
first one first.
noise-gate-report.mjs folds Play's version share into the comparison
expected_post = baseline x (1 - gated_share x 0.969)
where 0.969 is measured, not guessed (90d replay in sentry-noise.test.ts), and
grades the measured rate against that instead of against the 100%-uptake
endpoint. It reports the endpoint separately, so 'is it on track today' and
'will it clear the 3,500/mo org gate' stop being the same question, and it
inverts the model to print the IMPLIED on-device efficacy so the constant is
checked rather than trusted.
It refuses to grade two windows that look like results but are not: no
client_discard/before_send (nothing ran the gate) and 0% Play share. Absence of
evidence gets its own verdict, UNGRADED.
Runs in CI because neither credential (Sentry org token, Play service account)
exists outside GitHub Secrets — an agent picking up the 08-21 read locally is
stuck otherwise. Weekly cron records the trend regardless.
Verified against the live org: 1.2h after the production rollout it reads
UNGRADED, before_send=0, 4.95/h vs a 4.71/h baseline — which is exactly right,
no device has the build yet.
Refs AGE-105
Co-authored-by: engineer <engineer@macbookpro.lan>
* 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>
Tag pushes only reach the internal track; production is a separate manual
workflow_dispatch that rebuilds and gets its own versionCode. Document that
path and start a release-history table so the AGE-105 Sentry measurement
window has a real rollout date to anchor on.
v0.4.14: internal versionCode 150 (run 50), production versionCode 151
(run 51), submitted to the production track 2026-08-14 14:22 UTC.
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>
Ships 7c8bc7d (#169). Until this release, the filter exists only in main: every
installed build still uploads `connect timeout` / `connect server-unreachable`
and un-deduped retry loops, which is what makes opencode-mobile the org's #1
Sentry volume source (~4,500 events/month against a 3,500/month org quota).
User-visible change is deliberately small — quieter crash reporting, real
crashes unaffected — so the Play changelog says exactly that.
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>
* docs(waitlist): make the AGE-100 after-number a grep, not an inbox crawl
The doc told a future reader to split recovered mailto signups "by the App:
line", which in practice meant opening Chatwoot conversations one by one and
eyeballing bodies — slow, and easy to get wrong in the direction that matters
(missing a stamped mail reads as "clean").
The reconciler now does the split itself
(VibeBrowserProductPage#237) and prints it:
scanned=N synced=N skipped=N failed=N unstamped=N stamped=N builds=vX:N
Document that line as the read, with the exact gh commands, and state the pass
condition explicitly: stamped must be 0, because stamped>0 means a build that
carries the AGE-87 retry queue still fell through to mailto.
Refs AGE-100.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* docs(waitlist): record the first post-release reading of the build split
Proof the measurement pipeline works against the real inbox, not just a stub:
the first reconciler run carrying the split (2026-08-14 09:57 UTC) printed
scanned=34 synced=0 skipped=34 failed=0 unstamped=0 stamped=0
— the same 34 known conversations as the pre-release baseline. Labelled as one
hour of exposure rather than a verdict, so nobody mistakes it for the week-out
number that actually closes AGE-100.
Refs AGE-100.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
The publish workflow overwrites versionCode with github.run_number + 100, so
the gradle number (40) is not what Play reports. Production dispatch run 49 ->
versionCode 149. Without this row, play-version-share.mjs reports the new
release as an unknown versionCode and the AGE-100 after-number cannot be split
by build.
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>
AGE-61 asked for a number: what share of installs is still on a build older
than v0.4.8, where the ONLY waitlist path is a mailto: to a human inbox that
nothing reconciles back into the store (20 of 21 signups lost, 2026-08-03 →
2026-08-13). Answering it by hand is how it stays unanswered next quarter, so
this is a script, not a screenshot.
- scripts/play-version-share.mjs: Play Developer Reporting API
(crashRateMetricSet -> distinctUsers by versionCode) for the auto-updating
channel, plus --github for lifetime release-APK downloads per tag, which is
the only per-version signal the sideload channel emits. Mints its own token
from the service account we already ship to CI; no new deps, no new secret.
Play versionCodes are run_number+100, NOT the gradle ones — the mapping is
derived from the publish runs and documented inline (139 = v0.4.8).
- distribution/waitlist-signup-path-coverage.md: the measured answer.
The answer, 2026-08-14: Play is 0% stale (single reported versionCode 142 =
v0.4.10, ~90-100 daily users); the sideload channel is 25.9% stale (436 of
1682 lifetime APK downloads predate v0.4.8) and can never auto-update. There
is no iOS listing and no IzzyOnDroid presence, so nothing else contributes.
Two consequences worth stating plainly: the hourly mailto reconciler is
permanent infrastructure, not a stopgap; and shipping updates does NOT close
the leak, because on current builds the fallback still fires on timeout/5xx/
offline (src/lib/waitlist.ts:shouldFallbackToMailto).
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
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>
* growth: qualify 8 founding-member leads + draft outreach; park Stripe test key as founder-gated
Leads qualified from real repo engagement (issues, PRs, forks, stars), ranked
by purchase intent, each with contact channel and fit rationale. Outreach
message drafted with per-lead hooks and send rules.
Blocked on: Bitwarden Secure Note STRIPE_TEST_SECRET_KEY (folder opencode-mobile).
Vault only has OpenClawBot - STRIPE_SECRET_KEY which is sk_live_ and off-limits.
* docs(memory): log founding-member qualification and Stripe blocker
---------
Co-authored-by: Den <vibeteaichnologies@gmail.com>
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>
The session/chat screen's KeyboardAvoidingView used behavior={undefined}
on Android, relying entirely on the native android:windowSoftInputMode
adjustResize (set in AndroidManifest.xml) to shrink the window and push
the agent/model toolbar + composer above the keyboard.
Since the app adopted Expo's mandatory edge-to-edge display, Android no
longer resizes the window when the keyboard opens (the system assumes
insets are handled dynamically), so adjustResize became a no-op —
leaving the toolbar and input completely hidden behind the keyboard.
Switch to behavior="padding" on both platforms so KeyboardAvoidingView
pushes the composer up using its own JS-measured keyboard height,
independent of native window resize.
Co-authored-by: test <test@test.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(compliance): disclose email collection in Play Data Safety + align privacy docs (closes#143)
Google Play rejected cc.agentlabs.opencode (2026-07-22) because the Data
Safety declaration did not disclose collection of Email Address. Root
cause: the optional "OpenCode Connect" waitlist card on the Connect
screen (app/connection/add.tsx -> src/lib/waitlist.ts) collects an email
and forwards it to Brevo (email marketing/CRM) via the beta-signup
backend.
Audited all other PII surfaces and confirmed no other undisclosed
collection: Chatwoot support reports stay anonymous (no email/name),
Sentry strips URLs/tokens and sends no default PII, and PostHog
analytics uses only a random anonymous ID with coarse event properties.
Updates:
- distribution/play-listing.md: Data Safety table now declares
Personal info / Email address (collected, shared with Brevo,
optional, purpose account management); embedded privacy-policy draft
and app description updated to match.
- distribution/privacy-policy.md/.html + docs/privacy/index.html: new
section 3c discloses the waitlist email collection, third-party
services list adds Brevo, retention/rights sections and the Apple
Privacy Nutrition Label table updated accordingly.
- docs/playstore.md: checklist entry documents the rejection and points
to the fix.
- PUBLISHING.md: adds exact Play Console resubmission steps (Data
types -> Personal info -> Email address -> collected/shared/purpose)
plus a note on the earlier unrelated "Missing sign-in details" App
access blocker in case it resurfaces.
No app code changed; npm test (209 pass) and tsc --noEmit are clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): run required checks on docs-only PRs (unblock branch protection)
ios-ci.yml (which emits the required 'Typecheck and unit tests' check) had
paths-ignore for docs/**, docs-site/**, distribution/**, **/*.md. A required
status check that is path-filtered never runs on docs-only PRs, so those PRs
sit permanently in mergeStateStatus=BLOCKED (missing required check). Remove the
paths-ignore so required checks always run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
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>
* fix(ci): classify unresolved workflow failures
Replaces rolling historical failure escalation with active consecutive streaks and verifies Sentry zero/unavailable states.\n\nPlan: https://github.com/dzianisv/opencode-mobile/issues/139#issuecomment-5044502048
* fix(ci): scope failure streaks to default branch
Prevents pull-request failures from becoming production health signals for issue #139.
---------
Co-authored-by: engineer <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>
Add two new docs-site landing pages targeting search intent not covered
by existing pages:
- try-demo/ — "try an AI coding agent with no setup / no server", built
around the real offline demo mode (scripted bug-fix walkthrough: login
button + keyboard bug, reasoning, grep search, diff, permission prompt).
Closes the gap between "curious about the app" and "willing to spin up
a server" — zero existing pages address the no-setup demo path.
- gpt-gemini-android/ — "run GPT / ChatGPT / Gemini coding agent on
Android", mirroring the existing claude-code-android page for the two
other major providers opencode supports. Claude has its own page;
GPT and Gemini did not.
Both pages match the existing docs-site template exactly (same CSS,
header/footer nav, OG/meta/JSON-LD pattern, favicon, canonical URL) and
are added to sitemap.xml. Ships live on the next `deploy-docs.sh` run.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The Play publish workflow uses distribution/whatsnew/whatsnew-en-US (its
whatsNewDirectory), NOT the fastlane changelogs/*.txt (those feed F-Droid).
That file was stale at v0.4.7 — so 0.4.8/0.4.9/0.4.10 all shipped to the
internal track with outdated release notes. Updated it to 0.4.10 (<500 chars).
Also corrected PUBLISHING.md, which I'd previously written wrong: (a) app.json
android.versionCode is overridden by CI (github.run_number+100), so 0.4.10's
real Play versionCode is 142, not the app.json value — hand-bumping it is
pointless for Play; (b) Play release notes live in distribution/whatsnew, not
fastlane changelogs. Discovered while promoting 0.4.10 (the Console showed
versionCode 142, not the app.json 37).
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <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>
Supersedes v0.4.9 (versionCode 36, on internal but lacking these) so a SECURE,
current build is available in the Play library to promote to production:
- #124 reconnect resync (stuck 'processing' after network drop)
- #125 HIGH: biometric app-lock re-locks on background (was bypassable after
first unlock); connection password edits now persist
plus everything in v0.4.9 (demo mode, first-run clarity, core + notification
fixes). Promote versionCode 37 to production.
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>
Supersedes v0.4.8 (still on internal, not promoted) with everything merged
since: offline demo mode + first-run clarity (0.4.8), core session fixes
(#120: queued-message ghosting, selectSession race), and notification/
permission fixes (#121: permission never requested, wrong-session-on-back-nav,
misleading completion pushes, question double-reply). Production is on 0.4.5,
so promoting v0.4.9 gets users the full hardened app in one step.
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>
Reports the activation + demo funnels the app instruments (app_opened ->
connection_succeeded, demo_started -> demo_completed -> demo_exited_to_connect)
and the money metric: of users who started the offline demo, how many later
reached a working connection. Read-only HogQL.
Needs a PostHog PERSONAL API key (read scope) — the app only ships the
write-only ingest key, so funnel data can't be read without one. This makes
that the single remaining unblock: set POSTHOG_PERSONAL_API_KEY +
POSTHOG_PROJECT_ID and run it. Prints setup instructions if unset.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Captures the proven release flow (bump+changelog -> tag -> internal ->
promote) and documents that CI publishes to internal ONLY by design: the
service account lacks production scope, so a track=production workflow_dispatch
fails with 'The caller does not have permission' after building. Records both
the recommended Console promotion (add-from-library, no rebuild) and the
optional path to fully-automated prod releases (grant the SA production
permission first). Learned the hard way when v0.4.8's production dispatch
failed post-build.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
There is no auto-deploy for the docs site — gh-pages is updated manually,
which is why fixes (e.g. the opencode->opencode-ai guide correction in #113)
land on main but not on the live site. This ad-hoc process also risks wiping
the live F-Droid repo (gh-pages/fdroid/) since docs-site/ doesn't contain it.
scripts/deploy-docs.sh copies docs-site/ over gh-pages ADDITIVELY (never
--delete) and hard-aborts if fdroid/, privacy/, or .nojekyll would go missing
or the F-Droid repo index is empty. Supports --dry-run. A dry-run against the
current site shows it would ship exactly the pending changes (the guide
install-command fix + demo.gif/mp4 + updated screenshots) and touch nothing
under fdroid/.
Usage: bash scripts/deploy-docs.sh [--dry-run]
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>
npm 'opencode' returns 404 (does not exist); the real package is
'opencode-ai' (v1.18.3, bin: opencode). Three docs-site pages — including the
setup guide the app's empty state and demo CTA link to — told users to run
'npm install -g opencode', which hard-fails at step 1 and bounces them before
they ever connect. Every other place in the repo already uses opencode-ai.
Verified via npm registry. Corrects guide/, landing index, and
opencode-on-phone pages.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test(demo): use full-text regex matches in demo Maestro flow
The demo flow (added in #108, non-blocking lane) rendered correctly in CI
but failed its own assertions: it matched bare substrings ('login button',
'Tests passed') against Maestro's full-text regex matcher, which needs .*…*
to match a phrase inside a longer message. A CI run confirmed the demo
screen renders (S2 screenshot shows the user message, assistant reasoning,
and tool card) — only the assertions were wrong. Exact i18n labels
(Thinking / Permission Required / Allow) already matched. Flow-only; the
app and demo feature are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
* test(demo): scroll to diff after expanding the tool card
CI got further with the regex fix but then failed asserting diff-view-scroll:
the confirmed cause (failure screenshot) is that the Edit card is scrolled to
the bottom of the viewport to be tapped, so its expanded diff opens below the
fold, and assertVisible does not auto-scroll. Add a scrollUntilVisible for
diff-view-scroll before the assert. Flow-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
* test(demo): scroll to completion message before asserting it
Same below-the-fold pattern as the diff step: after approving the permission
at the bottom of the viewport, the 'Tests passed' completion renders further
down. Scroll to it before asserting. Preemptive, to avoid another CI cycle.
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>
Bundles the unreleased work sitting on main since v0.4.7:
- Offline demo mode (#108) — value without a server
- First-run clarity + fast-fail connect timeout (#107)
- Directory-browser stuck-state fix (#106)
Version + versionCode bumped and a user-facing changelog added so the owner
can cut the release (tag v0.4.8 / dispatch publish-play-store.yml). Does NOT
itself publish — releasing to production stays an owner action.
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 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>
* fix(e2e): stop asserting on SSE reply in activation-positive — CI-harness limitation, not a product bug (closes#90)
Extensive investigation (see PR #102 for the full trail) into "the
positive flow's assistant reply never renders" tried four independent
SSE client transports in src/lib/sdk.ts global.events(): the
already-shipped expo/fetch ReadableStream reader, a hand-rolled
XMLHttpRequest reader, react-native-sse, and react-native-fetch-api's
`reactNative: { textStreaming: true }`. Every one delivers exactly one
chunk right after connecting to the mock server and then nothing until
the connection closes, regardless of API choice or frame size (a ~4KB
padding experiment ruled out a buffer-size threshold).
A raw-socket probe (a plain BSD-sockets client with zero React Native
involvement, run via `adb shell` through the identical adb-reverse
tunnel the app uses) streamed every heartbeat from the mock server
incrementally in real time over the same connection. That rules out
adb-reverse and the mock's flush behavior and isolates the stall to
React Native Android's OkHttp-backed networking layer buffering a
long-lived streaming HTTP response in this specific Android-emulator +
Node-mock + adb-reverse combination — not a defect in any particular
client library.
There's no evidence this reproduces against a real opencode server on
a real device/network: issue #76's 65 affected users prove real SSE
connections stream live agent output in production (the bug they hit
was the 401-retry storm, not a missing reply). Since expo/fetch is the
already-shipped, production-proven transport and none of the
alternatives showed any advantage in this harness, the transport stays
unchanged.
What changes instead: .maestro/flows/activation-positive.yaml no
longer waits on the SSE-streamed reply, since asserting on it here
would assert on a CI-harness limitation, not real app behavior. It now
verifies everything reliably observable — consent, connect, session
creation, and the optimistic local echo of the sent message — and
activation-e2e.yml's `continue-on-error: true` (added because this
suite had never passed) comes off, so it blocks PRs on regressions in
what it does cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(e2e): repair stale "401" assertion in activation-negative-401 (refs #90)
Removing activation-e2e.yml's continue-on-error surfaced a second, unrelated
stale assertion once the suite was actually enforcing again: the negative
flow's connect-time-401 case asserts a literal "401" that PR #79 (401/403
auth-stop handling) and #103 (i18n) apparently moved out of what's
rendered — "Connection Failed" still passes, "401" now fails.
The alert body interpolates two pieces: probeConnection()'s summary (which
turns out to be misclassified as "connection actually works now" for this
case — diagnostics-classify.ts's `health.ok` only reflects "fetch() didn't
throw", not HTTP status, a separate real bug, out of scope for this PR) and
testConnection()'s caught error message, which is sdk.ts's
apiErrorFor(401, ...) text and always contains the mock's
`{"error":"Unauthorized",...}` body per src/lib/api-error.test.ts. Swapped
the assertion to "Unauthorized" and added a temporary console.log of both
pieces in app/connection/add.tsx to confirm exactly what renders from CI
logcat (removed once confirmed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(e2e): assert alert action buttons, not body text — native AlertDialog body isn't in Maestro's a11y tree (refs #90)
The diagnostic added last commit confirmed the Connection Failed alert's
body DOES contain the real error ("API Error: 401 -
{\"error\":\"Unauthorized\",...}", via logcat: '[connect] failure alert
content' logged both the (separately buggy, out-of-scope)
probeConnection summary and the correct testConnection error text). Yet
both "401" and "Unauthorized" assertions still failed against the same
on-screen alert. That means Maestro's accessibility-tree text matching
on this Android AlertDialog only sees the title, not the message body —
so no substring of the body was ever going to match.
Switched to asserting what's actually reachable: the title "Connection
Failed" (unchanged, already passing) plus both action button labels,
"OK" and "Share report" (src/lib/i18n/en.json common.ok /
common.shareReport). That still proves the test's real intent — a
visible, actionable error with a dismiss and a share-report path, never
a silent failure (issue #76) — using strings actually present in the
accessibility tree instead of guessing at unreachable body text.
Removes the temporary console.log diagnostic from
app/connection/add.tsx now that its purpose (confirming exactly what
renders) is done.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(e2e): split activation-e2e into blocking core + non-blocking newer flows (refs #90, refs #104)
With this PR's fixes, activation-positive and activation-negative-401
(the coverage issue #90 actually scoped) now run green — but removing
activation-e2e.yml's continue-on-error surfaced that four flows added
after the initial suite (#82's directory-picker/all-sessions/
variant-picker, #101's diff-scroll) have never once run to completion
in CI: they always sat behind whichever activation flow failed first,
so they were merged and have run unverified against the current
UI/mock this whole time. directory-picker fails immediately at
`id: directory-row-frontend`; the other three are untriaged.
Fixing four separate, previously-never-green UI surfaces is out of
#90's scope and unbounded in this PR. scripts/run-e2e-flows.sh now
splits the flow list into CORE_FLOWS (the two #90 covers — blocking,
fails the job on a regression) and NEWER_FLOWS (the four newer ones —
always run, each one's pass/fail reported via echo/::warning::, but
never fails the job). This lets activation-e2e.yml enforce the
activation coverage that's now verified, without either leaving it red
forever or spending unbounded time inside this PR chasing four
unrelated UI surfaces.
Filed #104 to track hardening each NEWER flow and moving it back into
CORE_FLOWS once confirmed green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>