diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 38f2460..21f91de 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -14,6 +14,7 @@ import { Alert, KeyboardAvoidingView, Platform, + Linking, } from "react-native" import { router, useFocusEffect } from "expo-router" import { Ionicons } from "@expo/vector-icons" @@ -27,6 +28,7 @@ import type { Session, Project } from "../../src/lib/sdk" import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat" import { groupByDirectory } from "../../src/lib/session-grouping" import { nameOf } from "../../src/lib/path-utils" +import { SETUP_GUIDE_URL } from "../../src/lib/links" function formatTime(timestamp: number, t: (key: string, opts?: Record) => string): string { const date = new Date(timestamp) @@ -435,6 +437,13 @@ export default function SessionsScreen() { {t("sessionsList.empty.addConnectionButton")} + Linking.openURL(SETUP_GUIDE_URL)} + testID="setup-guide-link" + > + {t("sessionsList.empty.setupGuideLink")} + ) } @@ -1013,6 +1022,14 @@ const styles = StyleSheet.create({ addButtonTextDark: { color: "#0a0a0a", }, + setupGuideLink: { + marginTop: 16, + }, + setupGuideLinkText: { + fontSize: 14, + fontWeight: "600", + color: "#6366f1", + }, loadingContainer: { flex: 1, justifyContent: "center", diff --git a/distribution/retention-analysis.md b/distribution/retention-analysis.md new file mode 100644 index 0000000..abfd81c --- /dev/null +++ b/distribution/retention-analysis.md @@ -0,0 +1,72 @@ +# Retention Analysis — opencode-mobile + +_Last updated: 2026-07-17_ + +## The number that matters + +- **Installs: 1K+** (Google Play, live listing, package `cc.agentlabs.opencode`). +- **7-day retention: ~0%** (per Notion "Retention Investigation"). + +Awareness is **not** the bottleneck anymore. Getting people to install works. Getting +them to *stay* does not. Every hour spent on more distribution channels is spent on the +wrong problem until activation is fixed. + +## Diagnosis: it's product-shape, not a bug + +A brand-new installer's path (traced in code): + +1. Launch → **telemetry consent modal first** (`app/_layout.tsx:157-168`) — a privacy + prompt before the user has seen any value. +2. Land on Sessions tab, empty state: "No Connection / Add a server connection to get + started" (`app/(tabs)/index.tsx:421-440`, `src/lib/i18n/en.json:258-259`). No hint + that this needs a *separate computer*. +3. Tap Add → Quick Connect form asking for **IP address + port + password** + (`app/connection/add.tsx:225-407`), hand-typed on a phone keyboard. +4. To get past this, the user must **already** have, on another machine: Node/npm, + `opencode-ai` installed, `opencode serve` running, and LAN or Tailscale reachability. +5. A wrong IP hangs the spinner up to **30s** before any feedback + (`REQUEST_TIMEOUT_MS=30_000`, `src/lib/sdk.ts:175`). + +**There is no demo, sample, or try-it mode anywhere in the app.** For any installer +without a self-hosted server already running — almost certainly the majority of casual +Play installs — the app is functionally inert after onboarding: empty state → a form +they can't fill → a "coming soon" mailing-list card (`app/connection/add.tsx:343-397`). +Nothing else to tap. + +The store listing makes it worse: the short description +("AI coding agent in your pocket. Stream, diff, approve — free & open source.") +sets **zero** expectation that a self-hosted server is required. People install expecting +a standalone app, hit a wall, and uninstall. That is the ~0% D7 mechanism. + +## Fixes, ranked by impact ÷ effort + +| # | Change | Impact | Effort | Owner-gated? | +|---|--------|--------|--------|--------------| +| 1 | Set the self-hosting expectation in the empty state + connect screen; fail-fast timeout | High | Low | No — shipping via PR | +| 2 | One-screen "what you need" pre-flight with an "I don't have a server yet" branch | High | Med | No | +| 3 | QR-code pairing (server prints QR of URL+creds; app scans) — kills manual IP/port/password typos | High | Med | No | +| 4 | Local demo/sandbox session — scripted walkthrough of chat/diff/approve, no server needed | High | Med-High | No | +| 5 | **Store-copy honesty**: move the server prerequisite into the short description + first screenshot | High (on D7) | Low | **Yes — Play Console** | +| 6 | Hosted "OpenCode Connect" so users need no server at all (the real fix) | Highest | High | **Yes — strategic/infra** | + +Items 1–4 are agent-shippable. Item 1 is in flight (branch +`fix/first-run-onboarding-clarity`). + +## The one decision only the owner can make + +**Do we optimize for install count or for activated users?** + +- Item 5 (put "needs a self-hosted server" in the store's short description) will + **reduce installs** — it filters out people who can never activate. It will **raise** + retention and store rating quality. This is a positioning tradeoff, not an engineering + one, so it is the owner's call, not an agent's. Recommended: yes, be honest up front — + a 4-star app with 300 real users beats a 2-star app with 1,000 bouncing ones. +- Item 6 (hosted OpenCode Connect) is the only change that removes the server prerequisite + entirely. The waitlist card already in the app signals intent. This is the actual + long-term retention fix and deserves a roadmap decision. + +## What this loop is doing about it + +- Shipping items 1 (and scoping 2) as reviewable PRs. +- Not touching the live Play listing (item 5) or infra (item 6) — surfacing them here for + the owner instead of acting unilaterally on positioning/strategy. diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 6d00b74..e2e305b 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -256,7 +256,8 @@ }, "empty": { "noConnectionTitle": "No Connection", - "noConnectionSubtitle": "Add a server connection to get started", + "noConnectionSubtitle": "Connect to a computer running opencode serve on your network or via Tailscale to get started.", + "setupGuideLink": "How to set up a server", "addConnectionButton": "Add Connection", "authFailedTitle": "Authentication Failed", "authFailedSubtitle": "{{name}} rejected your credentials. Check the username and password to reconnect.", diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index e6cef3c..a735555 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -256,7 +256,8 @@ }, "empty": { "noConnectionTitle": "无连接", - "noConnectionSubtitle": "添加服务器连接以开始使用", + "noConnectionSubtitle": "连接到运行 opencode serve 的电脑(同一网络或通过 Tailscale)以开始使用。", + "setupGuideLink": "如何设置服务器", "addConnectionButton": "添加连接", "authFailedTitle": "身份验证失败", "authFailedSubtitle": "{{name}} 拒绝了您的凭据。请检查用户名和密码后重新连接。", diff --git a/src/lib/links.ts b/src/lib/links.ts index f22a0f5..d3a22bc 100644 --- a/src/lib/links.ts +++ b/src/lib/links.ts @@ -1 +1,2 @@ export const PRIVACY_POLICY_URL = "https://dzianisv.github.io/opencode-mobile/privacy/" +export const SETUP_GUIDE_URL = "https://dzianisv.github.io/opencode-mobile/guide/" diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index f19b692..2726fbe 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -190,13 +190,25 @@ function createHeaders(config: ClientConfig): HeadersInit { return buildRequestHeaders(config) } -async function request(config: ClientConfig, path: string, options: RequestInit = {}): Promise { +// `timeoutMs` lets specific callers (e.g. the onboarding health-check) fail +// faster than the general REQUEST_TIMEOUT_MS used by real session calls. +// Leave it unset to get the default. +async function request( + config: ClientConfig, + path: string, + options: RequestInit = {}, + timeoutMs?: number, +): Promise { const url = `${config.baseUrl}${path}` const headers = { ...createHeaders(config), ...options.headers } - const response = await fetchWithTimeout(url, { - ...options, - headers, - }) + const response = await fetchWithTimeout( + url, + { + ...options, + headers, + }, + timeoutMs, + ) if (!response.ok) { const error = await response.text() @@ -206,7 +218,7 @@ async function request(config: ClientConfig, path: string, options: RequestIn return response.json() } -async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise { +async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { const parentSignal = options.signal if (parentSignal?.aborted) throw new Error("Request aborted") @@ -215,7 +227,7 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise const timeout = setTimeout(() => { timedOut = true controller.abort() - }, REQUEST_TIMEOUT_MS) + }, timeoutMs) const onParentAbort = () => controller.abort() parentSignal?.addEventListener("abort", onParentAbort) @@ -223,7 +235,7 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise return await fetch(url, { ...options, signal: controller.signal }) } catch (error) { if (timedOut) { - throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`) + throw new Error(`Request timed out after ${timeoutMs}ms`) } throw error } finally { @@ -235,7 +247,10 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise export function createClient(config: ClientConfig) { return { global: { - health: () => request(config, "/global/health"), + // `timeoutMs` overrides the default REQUEST_TIMEOUT_MS — used by the + // onboarding connection test to fail fast on a bad/unreachable IP + // instead of hanging for the full 30s (issue: first-run bounce). + health: (timeoutMs?: number) => request(config, "/global/health", {}, timeoutMs), // SSE event stream - returns async iterator // Pass an AbortSignal to cancel the connection async *events(signal?: AbortSignal): AsyncGenerator { diff --git a/src/stores/connections.ts b/src/stores/connections.ts index 606ee32..93a9ffa 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -11,6 +11,12 @@ const CONNECTIONS_KEY = "opencode_connections" const PASSWORDS_PREFIX = "opencode_password_" const RECENT_DIRS_KEY = "opencode_recent_dirs" const MAX_RECENT_DIRS = 10 +// A bad IP (unreachable host, wrong port) otherwise hangs for the full 30s +// general request timeout before the user sees a "connection failed" error — +// a first-run bounce driver. The interactive connect flow can afford to fail +// faster since a real server responds to /global/health in well under a +// second; this does NOT affect the timeout used for real session traffic. +const CONNECTION_TEST_TIMEOUT_MS = 12_000 // Cached auth so we can create directory-scoped clients without async SecureStore lookups interface ClientBase { @@ -259,7 +265,7 @@ export const useConnections = create((set, get) => ({ auth: buildAuth(connection.username, password), }) - await client.global.health() + await client.global.health(CONNECTION_TEST_TIMEOUT_MS) track(AnalyticsEvent.ConnectionSucceeded, { source }) return { ok: true } } catch (error) {