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>
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 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(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>
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>
- 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>
* 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).
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.
Fixes#35. The app was selecting claude-sonnet-4-6 (agent default) instead of gpt-5.4 (provider default), causing send_message failures in CI where only Azure provider is available.
Quick Connect (the DEFAULT add-connection mode) has no username field, so every
auth-build site (username && password ? {..} : undefined) produced undefined auth
whenever a password was set but username empty -> NO Authorization header -> 401
against a password-protected server. This is the common setup
(OPENCODE_SERVER_PASSWORD=... opencode serve) and a top install->churn cause:
user sets a password, can't connect, gives up.
Fix: extract buildAuth() to a pure, testable module; when a password is present but
username is empty, default username to 'opencode' (the server's own default,
OPENCODE_SERVER_USERNAME ?? 'opencode'). Advanced mode's explicit username is
preserved. Replaced all 6 inline ternaries in connections.ts.
+3 regression tests (68 total pass), typecheck clean. Found while setting up an
on-device emulator test of the connect flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract TOOL_STATUS + statusFromPart from stores/events.ts into a pure
status-labels.ts (type-only Part import, erased at runtime; events.ts delegates).
8 tests pin the live 'what is the agent doing' labels: reasoning/text/known-tool
mappings, shared labels (search/edit groups), unknown-tool degrade to
'Running <tool>...', and the no-tool/unknown-type fallthroughs. typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract parseUrl + classify from diagnostics.ts into a pure diagnostics-classify.ts
(diagnostics.ts imports + re-exports the types; behavior unchanged) so the
connection-failure decision tree — the user-facing 'why did connecting fail'
guidance — is unit-testable without react-native/expo. 15 tests: URL parsing
(port defaults, IPv4-vs-hostname, path/query stripping, malformed rejection) and
every classify branch (malformed, ok, tls, no-internet, health-failed, timeout,
server-unreachable incl. the hostname-only MagicDNS hint). typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract two pure helpers so they're testable without zustand/expo:
- settings-merge.ts: clampPageSize + forward-compatible mergeStoredSettings (the
upgrade path where stored data predates a new notification category must yield
the default, not undefined). stores/settings.ts now delegates.
- notify-format.ts: sanitizeBody (strip C0/DEL control chars, trim, cap at 200)
used for server-supplied notification text. stores/events.ts now imports it.
Behavior unchanged; typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the SSE chunk-buffering from sdk.ts into a pure SSEParser (sdk.ts delegates;
behavior unchanged) and pin the framing rules that are easy to break when an event
splits across network reads: partial trailing lines held until completed, frames
reassembled across 2-3 reads, [DONE] sentinel and empty/non-data lines filtered,
each frame emitted exactly once. typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the auth + directory-header encoding from sdk.ts into a pure, dep-free
headers.ts (sdk.ts now delegates — behavior unchanged) so the connection-critical
logic is unit-testable without expo/fetch. Add 8 header tests (ASCII passthrough,
non-ASCII/CJK percent-encoding stays header-safe, Basic auth, empty-dir handling)
and 8 logbuffer tests (serialization incl. circular-ref fallback, 200-entry ring
cap, copy semantics, formatting). typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(security): fail closed on biometric init error
H-03: setting isAuthenticated: true on initialization failure was a
security bypass — any crash during biometric setup granted full access.
Fail closed instead; user sees auth prompt on next open.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): use Crypto.randomUUID for connection IDs
H-04: Math.random() is not cryptographically random. Connection IDs are
used as SecureStore key suffixes; switch to expo-crypto randomUUID for
a secure source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(deps): pin expo-crypto to ~15.0.9
15.0.10 does not exist on npm; ~15.0.9 is the latest stable in the 15.x series compatible with Expo SDK 54.
* feat: add OpenCode Connect coming-soon waitlist card
Adds a discoverable 'OpenCode Connect — Coming Soon' card to the
add-connection quick-connect screen. Users can enter their email and
tap 'Join Waitlist' to send a pre-filled mailto. No backend required.
* fix(cua): detect actual screen dimensions and fix JSON parsing
- Get real screen size via `wm size` instead of hardcoding 1080x2400;
emulator is 1080x1920 so y-coordinates were systematically off
- Extract first JSON object via regex when model returns multiple objects
- Use AZURE_OPENAI_MODEL env var for deployment name (defaults gpt-5.4)
- Add AZURE_DEV_AI_* path for Azure AI Foundry endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): SHA-pin upload-google-play and sanitize notification bodies
M-02: Pin r0adkll/upload-google-play to commit SHA e738b9d (v1.1.5)
to prevent supply-chain hijack via tag mutation.
M-03: Sanitize all push notification bodies — strip control chars,
truncate to 200 chars. Prevents server-supplied strings (error messages,
file paths from permission patterns, session titles) from leaking
unbounded text into the OS notification drawer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(privacy): add telemetry consent gate for Sentry crash reporting
Sentry was always-on, violating F-Droid anti-feature policy and user
trust norms. Now gated behind explicit opt-in:
- First-launch consent modal (TelemetryConsentModal) shows once on
fresh install; user can Allow or Decline.
- Consent state persisted in expo-secure-store (survives restarts).
- Settings > Privacy section: crash reporting toggle + privacy policy link.
- initSentry() called only after consent granted — not on app start.
Closes#3 (partial)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(config): add real icons and complete iOS/Android app.json config
- Add 1024×1024 app icon, 432×432 adaptive icon foreground, 200×200 splash
- iOS: push notification entitlement (aps-environment: production), speech/
microphone/camera/photo usage descriptions for future features, disable
ITSAppUsesNonExemptEncryption
- Android: adaptive icon with dark background (#0F172A), versionCode: 1
- expo-notifications plugin wired in app.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(dist): add iOS CI workflow, README rewrite, CONTRIBUTING, and LICENSE
- publish-app-store.yml: EAS Build + TestFlight submission; runs on tag/release/
workflow_dispatch; bumps ios.buildNumber from github.run_number
- README: full rewrite — features, install badges, connection guide, contributing
- CONTRIBUTING.md: contribution guide for OSS contributors
- LICENSE: MIT
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(dist): add store listings, strategy, privacy policy, F-Droid/IzzyOnDroid templates
- distribution/strategy.md: monetization strategy (free client + opencode Cloud)
- distribution/play-listing.md: Google Play store copy (name, description, tags)
- distribution/app-store-listing.md: App Store listing copy
- distribution/privacy-policy.{md,html}: GDPR-compliant privacy policy
- distribution/PLAY_CONSOLE_SETUP.md: Play Console setup runbook
- distribution/ios-enrollment-runbook.md: Apple Developer Program enrollment steps
- distribution/SIGNING-KEY-FINGERPRINTS.md: keystore fingerprint for reproducible builds
- distribution/fdroid-submission/: F-Droid metadata template
- distribution/izzyondroid-submission/: IzzyOnDroid submission template
- distribution/whatsnew/: Play Store release notes (en-US)
- distribution/whatsnew-ios/: TestFlight release notes
- distribution/play-graphics/: Play Store screenshot placeholders
- distribution/app-store-graphics/: App Store screenshot placeholders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(telemetry): handle SecureStore failure + Android back button
- add .catch() on loadTelemetryConsent() so SecureStore rejection
shows the consent modal instead of blocking startup forever
- add onRequestClose={onDecline} to Modal so Android back button
records the decline rather than silently dismissing
- fix catch block in telemetry.ts to not clobber _resolved when
SecureStore read fails mid-session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ci): run gradlew clean to prevent stale modules.json duplicate
Sentry Gradle plugin writes modules.json to src/main/assets; cached
build intermediates contain an old copy → mergeReleaseAssets fails
with 'Duplicate resources'. Running clean before assembleRelease
clears the intermediate state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ci): remove android build output cache causing duplicate modules.json
Caching android/app/build/intermediates and android/app/.cxx causes
two issues:
1. Stale modules.json in intermediates → Duplicate resources error
2. .cxx CMake artifacts reference absolute paths → ninja clean fails
Keeping only Gradle distribution cache (~/.gradle) which is safe.
Expo prebuild regenerates android sources fresh each run anyway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(diagnostics): active connect-failure triage + Sentry + in-app share
Replaces the opaque "Connection Failed" / "Network request failed" dead-end
with on-device diagnostics that classify *why* a connect attempt failed.
On failure (quick connect and edit-connection test), the app now:
- runs parallel probes: target /global/health, target root, and a public
204 endpoint (internet reachability check)
- classifies the cause: malformed-url, no-internet, server-unreachable,
health-failed, tls-error, timeout
- shows a plain-English summary + a "Share report" button that copies a
full report (target URL, per-probe results w/ error.cause, device/app
info, recent log ring-buffer) to the clipboard and opens the share sheet
- captures the same structured context to Sentry (auto-upload), gated on
EXPO_PUBLIC_SENTRY_DSN so dev/CI builds work without secrets
New: src/lib/logbuffer.ts (ring buffer + logger), src/lib/diagnostics.ts
(regex URL parse — Hermes URL is incomplete — probe + report + share),
src/lib/sentry.ts (no-op-without-DSN wrapper, scrubs basic-auth from URLs).
Wired Sentry.wrap around RootLayout and initSentry() at module load.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(sentry): wire Sentry DSN + source-map upload env into build; bump to 0.2.2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>