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>
This commit is contained in:
@@ -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, unknown>) => string): string {
|
||||
const date = new Date(timestamp)
|
||||
@@ -435,6 +437,13 @@ export default function SessionsScreen() {
|
||||
{t("sessionsList.empty.addConnectionButton")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.setupGuideLink}
|
||||
onPress={() => Linking.openURL(SETUP_GUIDE_URL)}
|
||||
testID="setup-guide-link"
|
||||
>
|
||||
<Text style={styles.setupGuideLinkText}>{t("sessionsList.empty.setupGuideLink")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
72
distribution/retention-analysis.md
Normal file
72
distribution/retention-analysis.md
Normal file
@@ -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.
|
||||
@@ -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.",
|
||||
|
||||
@@ -256,7 +256,8 @@
|
||||
},
|
||||
"empty": {
|
||||
"noConnectionTitle": "无连接",
|
||||
"noConnectionSubtitle": "添加服务器连接以开始使用",
|
||||
"noConnectionSubtitle": "连接到运行 opencode serve 的电脑(同一网络或通过 Tailscale)以开始使用。",
|
||||
"setupGuideLink": "如何设置服务器",
|
||||
"addConnectionButton": "添加连接",
|
||||
"authFailedTitle": "身份验证失败",
|
||||
"authFailedSubtitle": "{{name}} 拒绝了您的凭据。请检查用户名和密码后重新连接。",
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -190,13 +190,25 @@ function createHeaders(config: ClientConfig): HeadersInit {
|
||||
return buildRequestHeaders(config)
|
||||
}
|
||||
|
||||
async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {
|
||||
// `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<T>(
|
||||
config: ClientConfig,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
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<T>(config: ClientConfig, path: string, options: RequestIn
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<Response> {
|
||||
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<HealthResponse>(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<HealthResponse>(config, "/global/health", {}, timeoutMs),
|
||||
// SSE event stream - returns async iterator
|
||||
// Pass an AbortSignal to cancel the connection
|
||||
async *events(signal?: AbortSignal): AsyncGenerator<Event> {
|
||||
|
||||
@@ -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<ConnectionsState>((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) {
|
||||
|
||||
Reference in New Issue
Block a user