Commit Graph

46 Commits

Author SHA1 Message Date
Den
3b6dab8c27 fix(auth): stop infinite SSE retry on 401/403, surface auth failures (#79)
* 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>
2026-07-17 02:22:53 -07:00
Den
b86ffec02f feat: edit/revert sent messages via server revert API — Closes #56 (#80)
* 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>
2026-07-17 02:18:05 -07:00
Den
0fdfb54d9d feat(waitlist): capture OpenCode Connect signups via beta-signup API (#92)
* 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>
2026-07-16 23:32:41 -07:00
Den
0bedad366b feat(feedback): deliver shared diagnostic reports to Chatwoot support inbox (#88)
* 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>
2026-07-16 23:08:40 -07:00
Den
819996f5fa fix(sessions): show all sessions across all projects (closes #48) (#50)
* fix(sessions): load all sessions across projects, not just active directory

Closes #48

Root cause: loadSessions() used connState.client which carries the
active connection's directory as x-opencode-directory header. The server
filters sessions by that directory, so only the current project's sessions
were visible.

Fix: call clientForDirectory(undefined) to get a no-header client.
The server then returns sessions from all projects.

The session row UI already showed a directory badge (shortDir from
session.directory), so no UI change is needed — each session already
displays its project folder name.

* fix(sessions): preserve directory when opening rows

Carry each listed session directory into the route so selection, messages, and follow-up operations use the matching project client.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-16 17:33:00 -07:00
Den
c1736bd426 feat: add reasoning effort picker to session screen (#47) (#51)
* 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>
2026-07-16 17:32:56 -07:00
engineer
3a724eb818 merge: test/activation-e2e — Maestro activation E2E + mock opencode server (reviewed: APPROVE after fixes) 2026-07-16 17:24:32 -07:00
engineer
b4483887ec merge: feat/activation-analytics — consent-gated PostHog activation funnel (reviewed: APPROVE after fixes) 2026-07-16 16:09:27 -07:00
engineer
c3cac2b8e5 fix(analytics): address review findings on activation-funnel events
- 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
2026-07-16 16:04:54 -07:00
engineer
f16dd91d88 merge: feat/directory-picker — browsable server directory picker (#49/#57) (reviewed: APPROVE after fixes) 2026-07-16 15:59:34 -07:00
engineer
7e9b3981c3 fix(directory-picker): address review — modal layering, root nav, stale state
- 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).
2026-07-16 15:57:31 -07:00
engineer
027c529ce5 merge main (feat/feedback-automation) into feat/activation-analytics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
2026-07-16 15:57:13 -07:00
engineer
01dd0191b3 test(activation): add Maestro E2E coverage for the activation flow
Adds deterministic end-to-end coverage for first-open -> telemetry consent
-> server URL entry -> connect -> send first message -> receive reply,
targeting the 0%-7-day-retention investigation (GitHub issue #76).

- tests/fixtures/mock-opencode-server.ts: dependency-free HTTP+SSE stub
  matching the REAL client protocol (src/lib/sdk.ts) — REST + a single
  long-lived GET /global/event SSE stream, no WebSocket. Supports a
  --fail-auth mode that 401s every request to exercise the connect-time
  auth-failure class.
- .maestro/flows/activation-positive.yaml: consent -> quick connect ->
  new session -> send message -> assert streamed reply renders, with a
  screenshot at every step (positive-S1..S8).
- .maestro/flows/activation-negative-401.yaml: same setup against the
  --fail-auth server, asserts Quick Connect's existing "Connection Failed"
  alert is shown (not silently swallowed) and that the connection is not
  saved. Flags in comments that Advanced-mode Save (handleAdvancedSave)
  still has no testConnection() check and is a known, uncovered gap.
- testID props added (no restructuring) to the screens/components the
  flows drive: TelemetryConsentModal, connection/add.tsx, tabs/index.tsx,
  session/[id].tsx, MessageBubble.
- .github/workflows/activation-e2e.yml: new CI job — Android emulator via
  reactivecircus/android-emulator-runner, builds the debug-signed APK,
  starts both mock server instances, runs both Maestro flows, uploads
  screenshots via actions/upload-artifact. Kept separate from the existing
  vision-driven cua-smoke.yml, which needs a live server + LLM and isn't
  suited to tight deterministic regression assertions.
- .gitignore: Maestro takeScreenshot output is never committed.

Verified locally: mock server exercised standalone via curl (health,
project/current, path, session create, SSE event ordering, message
persistence) in both normal and --fail-auth modes; both Maestro flow
files validated as well-formed YAML; tsc --noEmit clean on all changed
files. No lint script exists in this repo (N/A). Full emulator execution
was not run — no Android SDK/emulator available in this environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
2026-07-16 15:56:47 -07:00
engineer
d4555d45b4 fix(feedback): don't count errored sessions; mark review asked before requesting
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.
2026-07-16 15:53:24 -07:00
engineer
f0a7c1d868 feat: add browsable directory picker for new sessions and project switch
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.
2026-07-16 15:48:38 -07:00
engineer
ace8c19816 feat(analytics): add consent-gated activation-funnel analytics via PostHog
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
2026-07-16 15:48:16 -07:00
engineer
d3ee3d9e82 feat(feedback): prompt for a store review after successful sessions
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.
2026-07-16 15:46:28 -07:00
Dennis V
d1071b2a44 fix(ios): close final release review blockers
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-14 17:49:53 +00:00
Dennis V
f9b18a06f3 fix(privacy): stop telemetry on consent revocation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-14 06:30:09 +00:00
Dennis V
029bf2be04 fix(notifications): use user-friendly title and dedup keys for permission/question notifications
- Change permission notification title from `req.permission || 'Permission requested'`
  to the user-friendly 'Agent needs approval'; permission type + patterns now appear
  in the body (e.g. 'bash: echo hello') for context.
- Add `dedupeKey: `perm-${req.id}`` and `dedupeKey: `question-${req.id}``
  (60 s cooldown) to both events so a SSE reconnect after disconnect() clears state
  can't fire a second notification for the same pending request.
- Fix stale CUA-test comment that claimed 'Agent needs approval' did not exist;
  fallback assertion already matched correct title; update the comment to reflect
  the real events.ts behavior.

Closes #39

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 16:33:27 +00:00
Dennis V
6fee057355 test(sentry): extract pure scrub module + add unit tests — privacy regression guard
Extracts scrubUrl/scrubString/scrubObject into src/lib/scrub.ts (no RN deps)
so they can be tested with node --test without native module issues.

Adds src/lib/scrub.test.ts with 13 test cases covering:
- basic-auth credential stripping
- query-param secret redaction (token, api_key, password, access_token)
- clean URL passthrough
- mixed-param URL (only secrets redacted)
- embedded URL in error message strings
- nested object recursive scrubbing
- non-string value preservation

Closes #40

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 16:31:15 +00:00
Dennis V
472ff8d27d refactor(sessions): remove dead serverHome/path.get() plumbing
sessionScopeDirectory() always returned null and ignored its home arg, so
the scope plumbing in loadSessions/createSession was dead: scopeDir was
always null, listClient always the default client, and the lazy path.get()
fetch fed only that dead branch. Collapse both call sites to use the
connection's default client directly and delete the now-orphaned
sessionScope.ts helper and its test.

serverHome is intentionally KEPT in connections.ts: it is still consumed by
the directory switcher UI (DirectorySwitcher.tsx, app/(tabs)/index.tsx) for
~ path expansion, so it is not dead code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 05:20:41 +00:00
Den
f92f995b48 fix: let server decide model when no user selection exists (#37)
Registry defaults unreliable for deployed models; return null so server uses its configured model. Fixes #35.
2026-06-22 01:15:13 -07:00
Den
669618b7b7 fix: model selection precedence to prefer provider default over agent model (#36)
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.
2026-06-22 00:38:18 -07:00
Den
79d26f0fbd fix(sessions): scope default to server CWD, not $HOME (#32) (#33)
Recent sessions disappeared from the list when opencode serve was launched
outside $HOME (e.g. ~/workspace/opencode). The list path scoped to
server.home, but opencode-server resolves x-opencode-directory to a project
id by exact-match, not subtree prefix. $HOME mapped to the synthetic
'global' project, which never contained the user's workspace sessions.

Change the single-source-of-truth rule to return null (no scope header) when
the connection has no explicit directory. The server then uses its own CWD
project — the same project sessions are actually created in. Both list and
create paths still derive from sessionScopeDirectory, so #10's drift cure is
preserved.

Verified against 100.108.64.76:4096: no header returns the recent workspace
sessions (Opencode npm install, autopilot_exit, ...); header=$HOME returns
only the empty global project.

Closes #32
2026-06-21 22:01:05 -07:00
Den
d6e84ff513 fix: stale session client ref and CUA smoke test improvements (#30)
* fix(sessions): use latestConnState.client to avoid stale reference after reconnect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKGMRpgihA4io2frodqLjt

* fix(cua): lru_cache get_screen_size, remove redundant if-matches guard, drop inner import re

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKGMRpgihA4io2frodqLjt

* fix(cua): use center-x comparator for send button, defer screen_w fetch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKGMRpgihA4io2frodqLjt

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 03:38:06 -07:00
engineer
4b21ed73c2 fix(connect): default Basic-auth username to 'opencode' when password set (critical)
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>
2026-06-08 07:50:19 -07:00
engineer
42fa36f3cd fix(ux): 10 UI/UX bugs from pre-release audit (toward no-bug launch gate)
Found via parallel screen audit; each confirmed in code:
- AuthGate: auto-prompt biometrics on lock (useEffect was imported but unused)
- CodeBlock: horizontal scroll for long code lines (were wrapped/mangled)
- DiffView: horizontal scroll instead of numberOfLines=1 truncation
- chat: biometric-cancel on send shows feedback instead of silently dropping msg
- chat: send failure restores input + attachments and alerts
- chat: removed dead /compact + /clear builtin commands (advertised, no-op)
- sessions: delete + rename failures alert instead of silent; rename guarded
  against double-submit
- sessions: onRefresh spinner no longer hangs forever if a refresh rejects
- add/edit connection: validate URL has http(s):// scheme before save/test

typecheck clean, 65/65 unit tests pass. Runtime UI behavior still needs on-device
verification per the pre-posting test gate (HANDOFF §0b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 05:34:12 -07:00
engineer
6013d1d86d test(lib): cover agent-activity status labels (57→65 tests)
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>
2026-06-03 14:22:39 -07:00
engineer
39c4347092 test(lib): cover connection URL parsing + failure classification (42→57 tests)
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>
2026-06-03 14:20:27 -07:00
engineer
48bc314dfc test(stores): cover settings merge/clamp + notification sanitizer (29→42 tests)
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>
2026-06-03 14:01:29 -07:00
engineer
dd55ca471d test(lib): cover SSE event-stream framing (20→29 tests)
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>
2026-06-03 13:58:19 -07:00
engineer
3499167b60 test(lib): cover request-header building + log ring buffer (4→20 tests)
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>
2026-06-03 13:55:31 -07:00
engineer
059b5cc0f5 fix(sessions): don't flash error / refresh wrong session on send failure
sendMessage captured currentSession at call time; if the user switched sessions
while a prompt was in flight and it failed, the catch handler surfaced the error
on and refetched the NOW-current session. Clear the sending flag for the session
we actually sent to, but only set error / refreshMessages when it's still on
screen. Found via runtime bug audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:41:42 -07:00
engineer
66b89f74d4 fix(sessions): keep created-session scope through navigation and send (#10)
createSession now stamps the scope directory onto the returned session, and the
create-session navigation passes that directory to the detail screen. Previously
a freshly created home-scoped session was opened/addressed with the default
(CWD) client because the route carried no directory param — the same scope
mismatch class as #10, on the open/send path instead of the list path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:49:19 -07:00
engineer
bed0b6f622 refactor(sessions): single source of truth for session directory scope (#10)
Extract the list/create scope rule into sessionScopeDirectory() so loadSessions
and createSession can no longer drift apart — the root cause of #10 (sessions
empty after connect/create). Add a zero-dependency node:test regression guard
proving both paths resolve identically across all inputs, a 'test' npm script,
and exclude test files from the app typecheck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:46:48 -07:00
engineer
a5364668db fix(sessions): create session in the same scope the list reads (#10)
Root cause of the empty-sessions-after-connect bug, caught by the now-live E2E
smoke: createSession() created via the plain connection client (server CWD),
while loadSessions() lists home-scoped when the connection has no explicit
directory. When CWD != home the new session was invisible to the list.

Fix: createSession now mirrors loadSessions' directory scoping (home-scoped
client when no explicit directory), so a freshly created session reliably
appears. No change for connections with an explicit directory.

Verified by the connect-and-verify-sessions CUA smoke on push.
Refs #10.
2026-06-01 16:26:42 -07:00
Den
c9a57901c4 fix(ci): CUA smoke true-E2E with local opencode server (#15) (#18)
* chore: repoint OpenCode links to agentlabs.cc/opencode

agentlabs.cc/opencode and /opencode/privacy are now live (200). Repoint
README, distribution listings (Play/App Store/F-Droid/IzzyOnDroid/iOS),
docs, and in-app privacy links (settings + telemetry consent) from
www.vibebrowser.app/opencode to the canonical agentlabs.cc hub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): run local opencode server for CUA smoke true-E2E (#15)

GitHub-hosted runners can't reach the Tailscale dev server
(100.108.64.76:4096), so the CUA smoke always failed at session creation.

- Install opencode-ai and run `opencode serve` on the runner host; the
  Android emulator reaches it via 10.0.2.2. OPENCODE_URL now points there.
- Healthcheck /global/health before launching the app; dump server log on
  failure for diagnosis.
- Add --only-connect-scenario to the smoke script and run just the
  connect-and-verify-sessions path in CI: deterministic, needs no model
  backend. The scenario now creates a session if the list is empty, so a
  fresh server still yields a non-empty list.

This makes the smoke a true E2E and also exercises the #10 sessions-list
rendering path against a real server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): emulator smoke script is dash, not bash — drop brace-group healthcheck

android-emulator-runner runs the script: block under /usr/bin/sh (dash). The
multi-line `|| { ...; }` healthcheck was a dash syntax error (end of file
unexpected), failing the step before the smoke ran. Replace with a non-fatal
one-line re-check; the server was already health-gated in the prior step.

* docs(tasks): record smoke CI round 1 failure + dash fix

---------

Co-authored-by: engineer <engineer@opencode.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:32:06 -07:00
Den
32f7af4e11 fix(sessions): recover home-scoped list after fresh connect
* fix(sessions): use active connection client directly, remove roots filter

Root cause A: loadSessions was calling clientForDirectory(serverHome) which
scoped the session list to /home/azureuser — a different project than the
server's active CWD. Sessions in the current project (e.g. opencode-mobile)
were never returned.

Root cause B: roots:true filtered out sessions that have a parentID (sub-task /
AUTO-REVIEW sessions), hiding valid sessions from the list.

Fix: use connState.client directly (the connection's active directory) and drop
the roots filter so all sessions for that project are visible.

Also adds a verify_session_list CUA smoke scenario that navigates back to the
sessions tab after creating a session and asserts the list is non-empty —
covering the regression path that was previously untested.

* fix(sessions): fetch serverHome in addConnection so loadSessions shows correct sessions

Root cause: addConnection() built the HTTP client but never fetched serverHome
(only loadConnections and setActiveConnection did). When the user adds a new
connection (fresh install / first sign-in), serverHome = null, so loadSessions
fell through to connState.client (the server's CWD). On this dev server the CWD
is the deploy directory — 11 old May-19 sessions that are not the user's recent
work sessions.

Fix: addConnection now fetches currentProject + serverHome via the same
Promise.all as setActiveConnection, before calling set(). This ensures
loadSessions immediately uses clientForDirectory(serverHome) → the global
project → the user's actual recent parent sessions.

Also adds --opencode-url flag to the CUA smoke script, which appends a
connect_and_verify_sessions scenario that reproduces the regression:
  python scripts/android-cua-smoke.py --opencode-url http://100.108.64.76:4096

* fix(sessions): recover home scope after fresh connect

Resolve stale deploy-only session list by recovering server home during first load and keeping regression coverage in default Android CUA smoke and CI.

* chore(release): bump version to 0.4.0
2026-05-26 19:50:17 -07:00
Dzianis Vauchok
0ef3dd37b6 fix: use directory-aware client for delete, rename, command, permissions, questions
- sessions.ts: deleteSession looks up session.directory and uses clientFor()
  instead of bare client, so cross-project deletes hit the right server path
- index.tsx: submitRename uses clientForDirectory(session.directory) so renames
  work for sessions not owned by the active project directory
- index.tsx: handleSwitchDirectory calls catalog.load() after a directory switch
  so agents/commands/providers refresh for the new project
- session/[id].tsx: introduce sessionClient (directory-aware) and use it in
  handlePermissionReply, handleQuestionReply, handleQuestionReject, slash-command
  send, and the refreshPending bootstrap call; also add 'directory' to useEffect deps
- catalog.ts: remove debug console.log
2026-05-26 08:29:35 +00:00
Dzianis Vauchok
ce598e2a52 fix: use server home path for session listing to show all projects
When no project directory is explicitly selected, the session list was
filtered to the server's CWD, hiding sessions from other projects.

Fix by using the server's home path (fetched from /path) as the
x-opencode-directory header when listing sessions without an explicit
project directory. This ensures recent sessions across all projects
appear in the list.
2026-05-26 08:29:35 +00:00
Den
2b9b571d6e feat(privacy+dist): telemetry consent gate + app store distribution prep (#4)
* 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>
2026-05-25 17:42:03 -07:00
Dennis V
24667ee4f4 feat(crash): comprehensive crash + error reporting for v0.2.3
Adds full-stack crash capture so any unexpected failure — React render,
uncaught JS exception, unhandled promise rejection, or native — is
reported to Sentry with rich, scrubbed context. Expected operational
errors (timeouts, biometric cancel, etc.) stay local to preserve signal.

Changes:
- src/lib/sentry.ts: explicit native crash handlers, release/dist tags
  from app.json, beforeSend/beforeBreadcrumb URL+secret scrubbing,
  addBreadcrumb/captureException helpers, ErrorUtils + onunhandledrejection
  wrappers that always feed the in-memory log buffer (so offline Share
  Report includes the crash too).
- src/components/ErrorBoundary.tsx: new app-wide React boundary with a
  dark recovery screen — error message, top stack/component frames,
  Share Report (clipboard + native share sheet) and Try Again.
- src/lib/diagnostics.ts: buildCrashReport() reuses the existing
  DiagnosticReport pipeline so crashes and connect failures share one
  UI and one transport.
- _layout.tsx: wraps app in ErrorBoundary; emits app.lifecycle
  breadcrumb at startup.
- stores/{connections,events,sessions}.ts: high-signal breadcrumbs at
  connect, SSE connect/disconnect/reconnect, and session select.
- (tabs)/settings.tsx: fix unhandled promise on notificationsGranted().
- app.json: bump expo.version to 0.2.3.
- docs/prd.md, docs/tdd.md: new product + technical design docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:00:26 +00:00
Den
30be6636c2 feat(diagnostics): active connect-failure triage + Sentry + in-app share (#2)
* 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>
2026-05-22 17:07:44 -07:00
Den
e9991cb61b fix(connect): fix tailnet 'Connection Failed' (pasted-URL double scheme) + surface real error (#1)
* fix(connect): surface real connection error instead of generic dialog

testConnection swallowed the actual fetch error and returned a bare
boolean, so every failure collapsed to the same "Connection Failed"
text. On-device this made tailnet/LAN connect failures impossible to
diagnose (DNS vs timeout vs 401 vs cleartext all looked identical).

- testConnection now returns { ok, error } with the real error message
- add.tsx and [id].tsx dialogs show the error + target URL, plus a
  Tailscale/MagicDNS hint
- IP field keyboard: decimal-pad -> url, so tailnet hostnames can be
  typed (not just pasted)

Verified backend is healthy and reachable over tailnet (health 200,
port 4096 open in packet filter, cleartext present in shipped v0.2.0
APK), so the failure is client-side and was previously unobservable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(connect): normalize pasted IP/URL to avoid double scheme

Quick-connect blindly did `http://${ip}:${port}`. Pasting a full URL
(e.g. "http://100.108.64.76:4096", as the clipboard auto-paste offers)
produced "http://http://100.108.64.76:4096:4096" -> malformed URL ->
"Network request failed". This is the real tailnet connect failure:
typing a bare IP worked, pasting the displayed URL did not.

buildUrl now strips an existing http(s) scheme, drops any path, and
lifts a trailing :port out of the host field, so pasted full URLs,
host:port, and bare hosts all resolve to a single well-formed URL.

Reproduced and fixed on the Android emulator (paste full URL: fails
before, connects after).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 04:44:13 -07:00
Ubuntu
1843a7c37e Initial commit: OpenCode mobile app (extracted from opencode monorepo) 2026-05-17 19:52:54 +00:00