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:
Den
2026-07-17 17:55:12 -07:00
committed by GitHub
parent 2285f80e81
commit d54a74d3f5
7 changed files with 125 additions and 12 deletions

View File

@@ -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.",

View File

@@ -256,7 +256,8 @@
},
"empty": {
"noConnectionTitle": "无连接",
"noConnectionSubtitle": "添加服务器连接以开始使用",
"noConnectionSubtitle": "连接到运行 opencode serve 的电脑(同一网络或通过 Tailscale)以开始使用。",
"setupGuideLink": "如何设置服务器",
"addConnectionButton": "添加连接",
"authFailedTitle": "身份验证失败",
"authFailedSubtitle": "{{name}} 拒绝了您的凭据。请检查用户名和密码后重新连接。",

View File

@@ -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/"

View File

@@ -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> {

View File

@@ -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) {