Commit Graph

75 Commits

Author SHA1 Message Date
Den
af7da46c0f chore(release): v0.4.13 (versionCode 40) — waitlist retry queue (#166)
* chore(release): v0.4.13 (versionCode 40) — waitlist retry queue reaches users

Ships 2f81d34 (#165): failed waitlist signups are persisted on-device and
retried on app foreground instead of silently falling back to mailto.
Until this Play release, no user is running that fix.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(waitlist): stamp the app version into the mailto escape hatch

AGE-100 asks for the post-release mailto count "split by app version where the
mail body allows it". It did not allow it: the body was "Sign me up!\n\nEmail: x"
and nothing else, so a mail from an unreachable pre-v0.4.8 sideload is byte-identical
to one from a current build whose retry queue leaked. Those two readings have
opposite meanings — the first is the known permanent cohort, the second is a defect.

Now the escape hatch appends "App: OpenCode Mobile v<version>" (app.json, same
source Sentry uses). Absence of the line == pre-v0.4.13 build. waitlist.ts stays
free of react-native/JSON imports; the screen injects the version.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-14 02:02:53 -07:00
Den
2f81d34200 fix(waitlist): queue + retry failed signups instead of silently opening mailto (#165)
A signup that hit a network error, the 8s timeout or a 5xx was handed straight
to a `mailto:` composer. That path is lossy by design: it only works if the user
actually presses send, and if we keep reconciling the support inbox into Brevo
list 4 forever (AGE-61's hourly job). 20 of 21 signups were lost that way before
that reconciler existed, and Play's active base is ~100% on v0.4.10+ — so this
was current builds leaking, not just the ~436 stale sideloads.

Now:
- Failed-but-retryable signups are persisted on-device
  (`opencode.waitlist.pending.v1`, AsyncStorage) and retried on every app
  foreground (`app/_layout.tsx`) and on the Add Connection screen mount.
- 4xx stays non-retryable: the server will never accept that address, so we ask
  the user to fix it instead of queueing garbage forever.
- `mailto:` is now only ever opened by an explicit user tap ("Still not working?
  Email us instead"), shown after 3 failed attempts, or offered in an alert when
  device storage itself refuses the write — never as the silent default.
- The UI tells the truth: "Saved on this device — we'll finish signing you up as
  soon as you're back online" instead of implying it was sent.
- `WaitlistResult.fallback` -> `retryable`, `shouldFallbackToMailto` ->
  `isRetryableFailure`: the decision is about retry, not about mail.

Queue policy: dedupe by email, cap 5 entries, 30-day TTL, corrupt/foreign JSON
is discarded rather than replayed. Storage and the clock are injected so the
whole thing runs under `node --test` (16 new tests, incl. the acceptance case:
offline signup -> queued -> reconnect -> reaches the server, no mail client).

Also commits the AGE-61 measurement artifacts that were only ever local
(`distribution/waitlist-signup-path-coverage.md`, `scripts/play-version-share.mjs`)
and updates the doc's "current builds still leak" section, which this fixes.

Refs AGE-87, AGE-61.

Co-authored-by: engineer <engineer@macbookpro.lan>
2026-08-14 01:30:23 -07:00
Den
c92327570b security: add secret-scan CI gate + persisted-key allowlist tripwire (#163)
An unsolicited scanner reported CRITICAL "LLM output written to a
persistent memory store" findings against src/lib/notifications.ts:145,
src/lib/sdk.ts:392 and src/lib/session-grouping.ts:24. All three are
false positives: the cited lines are an in-memory notification dedupe
Map, a URLSearchParams limit param, and a bucket push inside a pure
grouping helper. The app persists nothing model-derived — sessions and
messages live on the server and are held in memory by the stores.

Two gates so that stays true and so the one class of report that WAS
real for us (credentials in git history) gets caught before a push:

- security-scan.yml: gitleaks on push/PR to main, full history fetch.
- persisted-keys.test.ts: enumerates every SecureStore write by key.
  A new persistence sink fails the suite until someone adds the key
  with a note saying what it holds — which is the moment to notice if
  it's model output rather than user config. Verified it trips by
  adding a throwaway "cache the assistant reply" write.

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-13 22:45:46 -07:00
Den
616753b6a1 fix(sessions): live-update open session screen; clear stuck loading without re-nav (closes #150) (#151)
Root cause: selectSession() re-runs on every navigation focus (#121's
resync), forcing isLoading back to true even when re-selecting the
session already shown on screen. That hides the whole conversation
(messages + composer) behind a spinner for as long as the redundant
GET takes -- while live SSE message/part updates keep flowing to the
store the entire time, just invisible behind the spinner. If that
GET is slow or stalls, the screen looks permanently "loading"; leaving
and re-entering only "fixes" it because it's a fresh retry, not
because anything was actually resolved.

Fix: only force isLoading=true for a genuinely cold load (no session
shown yet, or switching to a different one) via isColdSessionLoad().
A same-session re-focus refreshes in the background without hiding
existing (and live-updating) content. As a second safety net, any
live message.updated/message.part.updated/session.updated event for
the active session now clears isLoading unconditionally via
isLiveEventForSession() -- proof-of-life that unsticks the spinner
even if the GET itself never resolves.

Both are pure, unit-tested in src/lib/session-load-reconcile.ts.

Co-authored-by: test <test@test.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:51:32 -07:00
dzianisv
3ac7eb786c chore(release): v0.4.12 (versionCode 39) — global recent sessions + integration test (#145) 2026-07-23 00:59:16 -07:00
Den
bc1dbcf0cd feat(sessions): show global recent sessions without picking a directory (#144)
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>
2026-07-23 00:08:43 -07:00
Den
998c6c60cb fix: session-create + settings edge cases (double-tap, biometric stuck, recent-dir dupes) (#133)
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>
2026-07-21 23:03:45 -07:00
Den
1a069b53f5 fix(chat): fix 5 message-rendering correctness bugs (#132)
- MessageBubble: memo comparator only checked the last part's `.text`,
  which is always undefined for tool parts, so tool-state/token/cost
  updates never re-rendered when a tool part was last. Replace with a
  full reference-equality sweep over message + all parts (the store
  always replaces changed refs, so this catches every real change).
- DiffView: computeDiff's O(a.length*b.length) LCS table was unbounded,
  risking OOM/ANR on large diffs, and the rendered line list was
  unbounded too. Add a size-guarded fallback (simple truncated
  remove/add diff) and cap the normal path's rendered lines, both with
  a truncation marker. Extracted computeDiff into a plain
  diff-compute.ts module (mirrors src/lib/scroll-config.ts) so it's
  unit-testable with node:test, which can't render .tsx components.
- DiffView: normalize line endings (\r?\n) before diffing so a CRLF
  vs LF mismatch doesn't show a whole file as changed.
- Markdown: the module-scope singleton CustomRenderer's github-slugger
  never reset, so useMarkdown's keys climbed on every streamed token,
  remounting the whole subtree. Scope the renderer per `children` via
  useMemo instead.
- Markdown: theme objects used heading1/heading2/heading3/listItem,
  but react-native-marked's MarkedStyles expects h1/h2/h3/li, so the
  custom heading/list styling was silently dropped. Rename the keys in
  both themes.


Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:52:24 -07:00
Den
df4a3618c4 fix(connection): fix 6 correctness bugs in auth/connect/diagnostics flow (#131)
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>
2026-07-18 16:04:33 -07:00
Den
5b47f9ee13 fix: propagate send failures, guard image conversion, cleanup speech mic (#130)
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>
2026-07-18 15:01:36 -07:00
Den
179f88a4d8 feat(demo): funnel server-less users to the OpenCode Connect waitlist (#127)
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>
2026-07-18 11:53:21 -07:00
Den
f86265aa7c fix(security): re-lock biometric app-lock on background; persist edited password (#125)
Two issues from a security review of the credential/auth path (the review also
verified the fundamentals are solid — passwords in SecureStore, Sentry/analytics/
Chatwoot all scrub secrets).

1. HIGH: biometric app-lock never re-armed. authenticate() sets isAuthenticated
   =true once at cold start and lock() was never called (no AppState listener) —
   so 'Require Biometric to Open' was fully bypassable: after one unlock, anyone
   with brief physical access could reopen a backgrounded app straight into
   session history and connection details for the life of the JS process. Now an
   AppState 'background' listener calls lock() when the toggle is on. Fires on
   'background' only, so the biometric prompt / app switcher (transient
   'inactive') don't cause spurious re-locks.

2. Editing a connection's password did nothing: the edit screen's password field
   was never passed to updateConnection, which never wrote PASSWORDS_PREFIX — so
   a user rotating a server password silently kept using the old one. updateConnection
   now takes an optional password and writes it to SecureStore (blank = keep
   existing, since the field loads empty).

typecheck clean, 187/187 tests.


Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 08:49:42 -07:00
Den
b78ee442cc fix(sessions): resync session status on SSE reconnect to clear stuck 'processing' after network drop (closes #123) (#124)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:37:52 -07:00
Den
27362cee98 fix: request notification permission; resync session on focus; notif + reply bugs (#121)
Five correctness bugs from an adversarial review of the notification and
permission/approval paths (each verified against the code):

1. Notifications never worked for most users (HIGH): OS permission was only
   requested when a user manually toggled a Settings switch off→on. Since
   categories default on, that path never fired, permission stayed
   'undetermined', and send() silently no-op'd every notification. Now request
   it once on first live connection (in-context). (app/_layout.tsx)

2. Wrong-session data after back-navigation (HIGH): session screen reads a
   global store and its resync ran only on mount; the native stack keeps
   screens mounted underneath a pushed one, so returning to a session could
   show another session's messages and permission prompts — approving the wrong
   session's tool call. Re-select on focus via useFocusEffect. (app/session/[id].tsx)

3. 'Task completed' fired on aborted/errored runs (misleading, and a duplicate
   push alongside 'Session error'). Gate the notify by !aborted && !errored.
   (src/stores/events.ts)

4. Tapping a connection-drop notification (no sessionId) navigated to an empty
   '/session/' dead-end. Route to home instead. (app/_layout.tsx)

5. Double-tap on a single-select question sent two replies; the second hit an
   already-resolved request and popped a spurious 'Reply failed' alert. One-shot
   guard on reply/reject. (src/components/chat/QuestionPrompt.tsx)

Verified but intentionally NOT changed: 'completed' notifications default off
(a defensible anti-spam choice — the app still notifies when the agent needs
input). typecheck clean, 187/187 tests.


Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:04:15 -07:00
Den
4681126832 fix(session): don't drop queued messages; guard selectSession races (#120)
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>
2026-07-18 05:02:33 -07:00
Den
3c88e16ae5 fix(funnel): repair activation-path bugs found in end-to-end audit (#114)
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>
2026-07-17 21:47:13 -07:00
Den
1ea84f8236 chore(launch): reconcile README/store live-status + add demo-funnel analytics (#111)
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>
2026-07-17 19:49:47 -07:00
Den
c9ec92d8b7 feat(demo): add offline demo mode for zero-server activation (#108)
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>
2026-07-17 19:04:58 -07:00
Den
dfc38b3276 fix(e2e): directory-picker race, markdown a11y, variant-picker SSE softening (issue #104 cont.) (#106)
* fix(e2e): fix directory-picker race + markdown accessibility, soften variant-picker SSE assertion

Second iteration against real CI evidence from run 29617520311 (PR #105):

directory-picker still failed after enabling static snapPoints. The mock
server's own request log proved GET /file was still never called, meaning
DirectoryBrowserSheet's onChange never ran enter(). Root cause: the caller
(openBrowser in app/(tabs)/index.tsx) sets startDirectory via setState and
calls sheetRef.current?.expand() synchronously in the same handler. expand()
kicks off a reanimated-driven animation whose onChange fires before React
commits the re-render that would give the child the new startDirectory prop,
so the first onChange(index=0) captured the stale initial `null` and set
wasOpen=true — permanently blocking every later onChange for that open.
Mirrored startDirectory into a ref (updated inline on every render) so
handleSheetChange always reads the latest value regardless of which
render's closure actually fires.

diff-scroll still failed even after removing the nested FlatList — but the
new diagnostic screenshot showed the text WAS visually on screen while
Maestro's accessibility-tree-based assertion still couldn't find it for the
full timeout. That matches a real, still-open React Native Android bug
(facebook/react-native#46999, a reopened regression of #28952's fix):
selectable Text inside a FlatList row doesn't get its selectable/accessible
state applied correctly. react-native-marked's base Renderer hardcodes
`selectable` on every plain text node (text/strong/em/del/heading/codespan).
Overrode those in Markdown.tsx's CustomRenderer to render plain (non-
selectable) Text — code content stays copyable via CodeBlock's own Copy
button.

variant-picker: confirmed the model-selection fix worked completely (chip
appears, opens, selects, label updates) and the flow only fails afterward at
the exact same SSE-streamed-reply limitation documented in
activation-positive.yaml (issue #90 mode B — this CI harness's Android
emulator + Node mock + adb-reverse combination cannot deliver more than the
SSE stream's first chunk). Softened the post-send assertion to match
activation-positive's pattern: verify the optimistic local echo
(chat-bubble-user) instead of waiting on the unrenderable-in-CI reply.

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

* fix(ci): capture maestro hierarchy dumps in debug artifact upload

actions/upload-artifact excludes dotfiles/dot-directories by default, and
maestro's --debug-output nests the actual UI-hierarchy dump under a hidden
.maestro/tests/<timestamp>/ directory — so every activation-e2e run has been
silently uploading only logcat.txt/probe.txt and dropping the one artifact
most useful for diagnosing flow failures (issue #104). Set
include-hidden-files: true on that upload step.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:30:27 -07:00
Den
d54a74d3f5 fix(onboarding): clarify opencode-serve requirement + fail connect fast (retention) (#107)
* 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>
2026-07-17 17:55:12 -07:00
Den
2285f80e81 fix(e2e): harden activation-e2e newer flows (issue #104) (#105)
Root-caused directory-picker's directory-row-frontend failure: all four
in-app @gorhom/bottom-sheet sheets (DirectoryBrowserSheet, DirectorySwitcher,
ModelPicker, VariantPicker) provide static percentage snapPoints but rely on
v5's enableDynamicSizing default (true), which never resolves without content
wrapped in a size-reporting component — so useAnimatedDetents() permanently
early-exits and the sheets can never actually open. Set
enableDynamicSizing={false} on all four (they already have explicit
snapPoints, so dynamic sizing was never needed).

variant-picker's chip failure was a stale test assumption: src/lib/
model-selection.ts's chooseModelSelection() deliberately returns null for a
fresh session (issue #37/#35 — the provider registry default is unreliable),
so a brand-new session has no model selected and the reasoning-effort chip
has nothing to key off of. Added testIDs (model-chip, model-option-*) and
updated the flow to explicitly pick a model first, matching real usage.

diff-scroll's missing markdown text: switched src/components/markdown/
Markdown.tsx from react-native-marked's FlatList-based default export to its
useMarkdown() hook rendered into a plain View. The chat screen already nests
this inside its own *inverted* FlatList (one row per message) — a nested
VirtualizedList inside an inverted outer list is a known RN footgun where the
inner content can render at zero height instead of just warning. We already
forced scrollEnabled:false + a large initialNumToRender, defeating
virtualization anyway, so rendering the parsed blocks directly loses nothing.
Extended the existing react-native-marked .d.ts shim (added for a React
18/19 ReactNode mismatch) to also declare useMarkdown/useMarkdownHookOptions.

Added diagnostic screenshots to directory-picker.yaml and diff-scroll.yaml
at the previously-failing steps for faster triage if these regress again.

Closes #104.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:41:28 -07:00
Den
f945cab5c6 feat(i18n): extract remaining screens into en/zh-Hans catalogs; full Simplified Chinese coverage (closes #68) (#103)
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>
2026-07-17 10:52:41 -07:00
Den
0cac46cb36 test(diff): automate DiffView/CodeBlock horizontal-scroll coverage (closes #21) (#101)
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>
2026-07-17 10:37:05 -07:00
Den
9a24cea61f feat(connect): list server filesystem roots/drives in directory browser (closes #57) (#100)
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>
2026-07-17 10:24:37 -07:00
Den
63f3ec3c7e docs+consent: disclose activation analytics honestly across consent modal, privacy policy, and store docs (#81)
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>
2026-07-17 03:28:00 -07:00
Den
b52fa52a4c test(e2e): instrument mock SSE + client stream + fix reachability probe to attribute #90 mode-B (#98)
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>
2026-07-17 03:20:37 -07:00
Den
2da0fdf109 test: E2E coverage for directory picker, all-sessions, variant picker. Refs #46 #48 #47 #49 #57. (#82)
* test: E2E coverage for directory picker, all-sessions, variant picker

Extend the Maestro suite for the features merged into main today:
DirectoryBrowserSheet's server-folder picker, the directory-less
all-sessions-across-projects list (+ the #46/#48 open-across-project
regression), and VariantPicker's reasoning-effort chip.

- tests/fixtures/mock-opencode-server.ts: GET /file (directory-scoped via
  the x-opencode-directory header) with a small fake tree, GET /project
  for the "Server Projects" section, POST /session honoring the directory
  header, GET /session/:id (needed to open a session from the all-sessions
  list), GET /provider variants for VariantPicker, and an optional
  --seed-sessions mode that pre-populates two sessions across two
  directories. --fail-auth mode is untouched.
- .maestro/flows/directory-picker.yaml, all-sessions.yaml,
  variant-picker.yaml: three new flows, run in the same emulator session
  as the existing activation flows.
- Additive testIDs on DirectoryBrowserSheet, the "Browse Folders" row,
  session list rows, the variant chip, and VariantPicker rows.
- .github/workflows/activation-e2e.yml: two more mock server instances
  (4098 seeded, 4099 fresh) and three more maestro test steps.

Verified: tsc --noEmit clean, all 108 existing unit tests pass, every new
mock endpoint curled against its real shape read from the app code, YAML
validated. No Android emulator available locally to run the Maestro flows
themselves.

* test(mock): enforce per-directory session scoping so #46/#48 coverage can fail

Review finding (HIGH): GET /session/:id and /session/:id/message ignored
x-opencode-directory, so all-sessions.yaml could not fail if the directory
threading fix regressed. The mock now mirrors the real server's per-directory
workspace scoping:

- GET /session/:id and GET /session/:id/message 404 unless the request's
  x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the stored
  session's directory.
- GET /session without ?roots=true is scoped to the request's directory;
  loadSessions()'s directory-less roots=true call still returns everything.
- Document the port-4099 shared-state coupling between directory-picker and
  variant-picker flows, and why all-sessions.yaml now has teeth (flow comment).

Curl-verified: correct header 200, wrong/no header 404, scoped vs roots
listing, create-then-open paths for all three flows, --fail-auth untouched.
tsc clean, 108/108 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E

* fix(e2e): widen connect-handshake wait past client's own 30s timeout

Run 29546383612 (7cbd3a6, first real emulator execution of these flows)
failed on activation-positive.yaml: "Assert that id: connection-status-dot
is visible" timed out after the flow's 20s extendedWaitUntil, right after
tapOn connect-submit-button.

The mock server itself is fast (verified locally: health + project/current
+ path respond in ~30ms total), so this isn't a mock fidelity gap. But
Quick Connect's testConnection()/addConnection() path chains up to 3
fetches (health, then project.current + path.get in parallel), and each
individual fetch is capped by src/lib/sdk.ts REQUEST_TIMEOUT_MS = 30_000 —
strictly longer than the 20s the flow was willing to wait. A first-attempt
emulator-to-host (10.0.2.2) connection that's merely slow to establish,
rather than outright failing, would blow past the test's wait before the
app's own client-side timeout even fires.

Bump the connect -> connection-status-dot / "Connection Failed" waits from
20000 to 40000 across all 5 flows that share this pattern
(activation-positive, activation-negative-401, all-sessions,
directory-picker, variant-picker) so the wait is never shorter than the
code path it's gating on. Assertions are unchanged — still requires the
real dot / real error text, just with a timeout that isn't racing the
client.

Verified locally: typecheck clean, all 108 unit tests pass, YAML parses,
mock server confirmed fast under direct curl. Emulator behavior itself
(whether 40s consistently clears it) is unverified until the next CI run.

* fix(e2e): use adb reverse + 127.0.0.1 instead of 10.0.2.2; capture logcat/maestro debug

Root cause of the activation-e2e failure (connect step timed out, ~0 requests
reaching the mock): the 10.0.2.2 host alias is unreliable under the headless
emulator-runner — the app's http://10.0.2.2:4096/global/health never completed,
so connection-status-dot never rendered.

- run-e2e-flows.sh: single script (fixes cd-per-line fragility) that adb-reverses
  each mock port (4096-4099) into the emulator's localhost, runs every flow with
  --debug-output, and dumps logcat on exit.
- All flows now connect to 127.0.0.1:<port> (the adb reverse target).
- Upload maestro-debug (UI hierarchy on failure) + logcat as artifacts so future
  failures are diagnosable instead of blind.

* fix(e2e): connect via 127.0.0.1:PORT in IP field, stop typing into port input

Root cause of every activation-e2e connect failure (proven by the app's own
logcat diagnostic: '[diag] probe start http://127.0.0.1:40966 ... server
unreachable'): the port field defaults to useState("4096"), and the flow's
eraseText + inputText "4096" raced the controlled number-pad input, leaving
"40966" — nothing listens there, so connect always failed. This was never a
10.0.2.2 / adb reverse issue.

Fix: buildUrl already extracts host:port from the IP field, so enter
127.0.0.1:<port> there and remove the flaky port-field steps entirely.
pastedPort overrides the default port state, so each flow's port is
deterministic (4096 positive / 4097 negative / 4098 all-sessions / 4099
directory+variant).

---------

Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:55:25 -07:00
Den
baf73058fd feat(i18n): add i18n infrastructure + Simplified Chinese, localize Settings screen (refs #68) (#97)
- 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>
2026-07-17 02:47:18 -07:00
Den
90093f4bac feat(sessions): group conversation list by project directory (closes #67) (#96)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:22:57 -07:00
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