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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user