diff --git a/.maestro/flows/demo.yaml b/.maestro/flows/demo.yaml
new file mode 100644
index 0000000..dd0fec7
--- /dev/null
+++ b/.maestro/flows/demo.yaml
@@ -0,0 +1,94 @@
+appId: cc.agentlabs.opencode
+name: Demo mode - offline scripted walkthrough (no server, zero-server activation)
+---
+# Covers the "Try a demo" activation path added for the ~0% 7-day retention
+# problem: an installer with no self-hosted `opencode serve` server used to
+# hit a dead end at the no-connection empty state (see activation-positive.yaml
+# / activation-negative-401.yaml for the "has a server" paths). This flow
+# never starts a mock opencode server and never taps add-connection-button —
+# app/demo.tsx (src/lib/demo-script.ts) is fully offline, hardcoded, local
+# data rendered through the same chat components a real session uses.
+#
+# Verifies: the empty-state CTA exists and doesn't disturb the pre-existing
+# add-connection-button (same testID, same position — activation-positive.yaml
+# still taps it first), the demo screen renders a scripted conversation
+# (reasoning -> tool call -> diff -> permission prompt) through the real
+# MessageBubble/ToolCallCard/DiffView/PermissionPrompt components, the
+# permission step responds locally (no network — there is no server to talk
+# to), and the closing CTA actually routes to the real connect form
+# (app/connection/add.tsx), not a fake/dead link.
+
+- launchApp:
+ clearState: true
+- assertVisible:
+ id: "telemetry-consent-card"
+- tapOn:
+ id: "telemetry-decline-button"
+
+- assertVisible:
+ text: "No Connection"
+- takeScreenshot: demo-S1_empty_state
+# add-connection-button must still be the first/only thing tapped here — this
+# flow only ever taps it in this reachability check (visible, not tapped),
+# to lock in that its position wasn't disturbed by the new button.
+- assertVisible:
+ id: "add-connection-button"
+- tapOn:
+ id: "try-demo-button"
+
+- assertVisible:
+ id: "demo-screen"
+- assertVisible:
+ id: "demo-banner"
+- takeScreenshot: demo-S2_demo_screen_opened
+
+# Scripted conversation renders through the real chat components.
+- assertVisible:
+ id: "chat-bubble-user"
+- assertVisible:
+ text: "login button"
+- assertVisible:
+ id: "chat-bubble-assistant"
+- assertVisible:
+ text: "Thinking"
+- takeScreenshot: demo-S3_conversation
+
+# Tool call card is collapsed by default (same behavior as a real session) —
+# expand it to reveal the diff.
+- scrollUntilVisible:
+ element:
+ text: "Edit src/components/LoginButton.tsx"
+ direction: DOWN
+ timeout: 10000
+- tapOn:
+ text: "Edit src/components/LoginButton.tsx"
+- assertVisible:
+ id: "diff-view-scroll"
+- takeScreenshot: demo-S4_diff_view
+
+# Permission prompt (approve/permission step) — replying only updates local
+# demo state, never a real sessionClient.permission.reply() network call.
+- scrollUntilVisible:
+ element:
+ text: "Permission Required"
+ direction: DOWN
+ timeout: 10000
+- takeScreenshot: demo-S5_permission_prompt
+- tapOn:
+ text: "Allow"
+- assertVisible:
+ text: "Tests passed"
+- takeScreenshot: demo-S6_permission_approved
+
+# Closing CTA routes to the REAL connect form, not a dead link.
+- scrollUntilVisible:
+ element:
+ id: "demo-connect-button"
+ direction: DOWN
+ timeout: 10000
+- takeScreenshot: demo-S7_cta
+- tapOn:
+ id: "demo-connect-button"
+- assertVisible:
+ id: "connect-ip-input"
+- takeScreenshot: demo-S8_connect_form_reached
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 21f91de..27861dc 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -444,6 +444,18 @@ export default function SessionsScreen() {
>
{t("sessionsList.empty.setupGuideLink")}
+ {/* No-server activation path (retention): a fully offline scripted
+ demo, isolated from real connect/session state — see app/demo.tsx. */}
+ router.push("/demo")}
+ testID="try-demo-button"
+ >
+
+
+ {t("sessionsList.empty.tryDemoButton")}
+
+
)
}
@@ -1030,6 +1042,28 @@ const styles = StyleSheet.create({
fontWeight: "600",
color: "#6366f1",
},
+ tryDemoButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ marginTop: 20,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: "#8b5cf6",
+ },
+ tryDemoButtonDark: {
+ borderColor: "#a78bfa",
+ },
+ tryDemoButtonText: {
+ fontSize: 14,
+ fontWeight: "600",
+ color: "#6d28d9",
+ },
+ tryDemoButtonTextDark: {
+ color: "#a78bfa",
+ },
loadingContainer: {
flex: 1,
justifyContent: "center",
diff --git a/app/demo.tsx b/app/demo.tsx
new file mode 100644
index 0000000..df14092
--- /dev/null
+++ b/app/demo.tsx
@@ -0,0 +1,124 @@
+import { useCallback, useMemo, useState } from "react"
+import { View, Text, ScrollView, TouchableOpacity, StyleSheet, useColorScheme, Linking } from "react-native"
+import { Stack, useRouter } from "expo-router"
+import { Ionicons } from "@expo/vector-icons"
+import { useTranslation } from "react-i18next"
+import { MessageBubble, PermissionPrompt } from "../src/components/chat"
+import { buildDemoScript, buildDemoCompletionMessage, buildDemoDenialMessage } from "../src/lib/demo-script"
+import { SETUP_GUIDE_URL } from "../src/lib/links"
+
+// Fully offline, scripted walkthrough for installers with no self-hosted
+// opencode server. ISOLATION: every message/part below is hardcoded local
+// data built once with useMemo — this screen never calls useSessions,
+// useConnections, useEvents, or any sdk.ts client method, so it cannot
+// create a real session, touch the network, or corrupt real app state. The
+// permission "reply" only flips local component state (below); it never
+// calls sessionClient.permission.reply the way app/session/[id].tsx does.
+export default function DemoScreen() {
+ const router = useRouter()
+ const colorScheme = useColorScheme()
+ const isDark = colorScheme === "dark"
+ const { t } = useTranslation()
+
+ // Built once per mount from pure, hardcoded data (src/lib/demo-script.ts).
+ const script = useMemo(() => buildDemoScript(), [])
+ const [reply, setReply] = useState<"once" | "always" | "reject" | null>(null)
+
+ const handleReply = useCallback((r: "once" | "always" | "reject") => {
+ setReply(r)
+ }, [])
+
+ const completion = useMemo(() => {
+ if (!reply) return null
+ return reply === "reject" ? buildDemoDenialMessage() : buildDemoCompletionMessage()
+ }, [reply])
+
+ const [userMessage, assistantMessage] = script.messages
+
+ return (
+ <>
+
+
+
+
+ {t("demo.banner")}
+
+
+
+
+
+
+ {!reply && }
+
+ {completion && (
+
+ )}
+
+
+ {t("demo.ctaTitle")}
+ {t("demo.ctaSubtitle")}
+ router.push("/connection/add")}
+ testID="demo-connect-button"
+ >
+ {t("demo.connectButton")}
+
+ Linking.openURL(SETUP_GUIDE_URL)}
+ testID="demo-setup-guide-link"
+ >
+ {t("demo.setupGuideLink")}
+
+
+
+
+ >
+ )
+}
+
+const s = StyleSheet.create({
+ container: { flex: 1, backgroundColor: "#ffffff" },
+ containerDark: { backgroundColor: "#0a0a0a" },
+ textWhite: { color: "#ffffff" },
+ metaDark: { color: "#888888" },
+
+ banner: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ backgroundColor: "#f5f3ff",
+ borderBottomWidth: 1,
+ borderBottomColor: "#e9d5ff",
+ },
+ bannerDark: { backgroundColor: "#1a1030", borderBottomColor: "#2a1a4a" },
+ bannerText: { fontSize: 13, fontWeight: "600", color: "#6d28d9", flex: 1 },
+
+ scrollContent: { padding: 16, paddingBottom: 40 },
+
+ ctaCard: {
+ marginTop: 8,
+ padding: 20,
+ borderRadius: 16,
+ backgroundColor: "#f5f5f5",
+ alignItems: "center",
+ },
+ ctaCardDark: { backgroundColor: "#1a1a1a" },
+ ctaTitle: { fontSize: 17, fontWeight: "700", color: "#0a0a0a", textAlign: "center" },
+ ctaSubtitle: { fontSize: 13, color: "#666666", marginTop: 6, textAlign: "center", lineHeight: 18 },
+ connectButton: {
+ marginTop: 16,
+ backgroundColor: "#0a0a0a",
+ paddingHorizontal: 24,
+ paddingVertical: 12,
+ borderRadius: 8,
+ width: "100%",
+ alignItems: "center",
+ },
+ connectButtonText: { color: "#ffffff", fontWeight: "600", fontSize: 15 },
+ setupGuideLink: { marginTop: 14 },
+ setupGuideLinkText: { fontSize: 14, fontWeight: "600", color: "#6366f1" },
+})
diff --git a/scripts/run-e2e-flows.sh b/scripts/run-e2e-flows.sh
index 929427c..6f4a40e 100755
--- a/scripts/run-e2e-flows.sh
+++ b/scripts/run-e2e-flows.sh
@@ -27,7 +27,9 @@ APK="android/app/build/outputs/apk/release/app-release.apk"
# selectors/seeding surface) and moving each back into CORE once confirmed
# green.
CORE_FLOWS=(activation-positive activation-negative-401)
-NEWER_FLOWS=(directory-picker all-sessions variant-picker diff-scroll)
+# demo.yaml (offline "Try a demo" mode) needs no mock server — it never
+# connects — so it's safe to run alongside the others here unmodified.
+NEWER_FLOWS=(directory-picker all-sessions variant-picker diff-scroll demo)
mkdir -p "$ROOT/artifacts/screenshots" "$ROOT/artifacts/diag"
echo "== installing APK =="
diff --git a/src/lib/demo-script.test.ts b/src/lib/demo-script.test.ts
new file mode 100644
index 0000000..ea5aa1f
--- /dev/null
+++ b/src/lib/demo-script.test.ts
@@ -0,0 +1,74 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import {
+ buildDemoScript,
+ buildDemoCompletionMessage,
+ buildDemoDenialMessage,
+ DEMO_SESSION_ID,
+} from "./demo-script.ts"
+
+test("buildDemoScript: returns one user message followed by one assistant message", () => {
+ const { messages } = buildDemoScript(1_000_000)
+ assert.equal(messages.length, 2)
+ assert.equal(messages[0].role, "user")
+ assert.equal(messages[1].role, "assistant")
+ assert.ok(messages.every((m) => m.sessionID === DEMO_SESSION_ID))
+})
+
+test("buildDemoScript: user message time is before the assistant message's", () => {
+ const { messages } = buildDemoScript(1_000_000)
+ assert.ok(messages[0].time.created < messages[1].time.created)
+})
+
+test("buildDemoScript: assistant parts include reasoning, a search tool call, and an edit tool call with a diff", () => {
+ const { messages, parts } = buildDemoScript(1_000_000)
+ const assistantParts = parts[messages[1].id]
+ assert.ok(assistantParts, "assistant message must have parts")
+
+ const reasoning = assistantParts.filter((p) => p.type === "reasoning")
+ assert.equal(reasoning.length, 1)
+
+ const tools = assistantParts.filter((p) => p.type === "tool")
+ assert.equal(tools.length, 2)
+ assert.ok(tools.some((p) => p.tool === "grep"))
+
+ const edit = tools.find((p) => p.tool === "edit")
+ assert.ok(edit, "expected an edit tool call")
+ const input = edit!.state?.input as { oldString?: string; newString?: string } | undefined
+ assert.ok(typeof input?.oldString === "string" && input.oldString.length > 0)
+ assert.ok(typeof input?.newString === "string" && input.newString.length > 0)
+ assert.notEqual(input?.oldString, input?.newString)
+})
+
+test("buildDemoScript: every part references a real message id", () => {
+ const { messages, parts } = buildDemoScript(1_000_000)
+ const messageIDs = new Set(messages.map((m) => m.id))
+ for (const [key, list] of Object.entries(parts)) {
+ assert.ok(messageIDs.has(key))
+ for (const part of list) assert.equal(part.messageID, key)
+ }
+})
+
+test("buildDemoScript: permission has a non-empty type and pattern", () => {
+ const { permission } = buildDemoScript(1_000_000)
+ assert.ok(permission.id.length > 0)
+ assert.ok(permission.permission.length > 0)
+ assert.ok(permission.patterns.length > 0)
+})
+
+test("buildDemoScript: is deterministic for a fixed `now`", () => {
+ const a = buildDemoScript(42)
+ const b = buildDemoScript(42)
+ assert.deepEqual(a, b)
+})
+
+test("buildDemoCompletionMessage and buildDemoDenialMessage produce distinct, non-empty text", () => {
+ const completion = buildDemoCompletionMessage(1_000_000)
+ const denial = buildDemoDenialMessage(1_000_000)
+ assert.notEqual(completion.message.id, denial.message.id)
+ assert.equal(completion.message.role, "assistant")
+ assert.equal(denial.message.role, "assistant")
+ assert.ok(completion.parts[0].text!.length > 0)
+ assert.ok(denial.parts[0].text!.length > 0)
+ assert.notEqual(completion.parts[0].text, denial.parts[0].text)
+})
diff --git a/src/lib/demo-script.ts b/src/lib/demo-script.ts
new file mode 100644
index 0000000..9b2ef0d
--- /dev/null
+++ b/src/lib/demo-script.ts
@@ -0,0 +1,167 @@
+// Pure, hardcoded data for the offline "Try a demo" flow (app/demo.tsx).
+// No React Native imports, no store access, no network — unit-testable with
+// node --test, and safe to import from a screen that must never touch a
+// real connection. The shapes match src/lib/sdk.ts's Message/Part exactly
+// so the demo can render through the SAME presentational components
+// (MessageBubble, ToolCallCard, DiffView, PermissionPrompt) real sessions
+// use, instead of a parallel fake UI.
+import type { Message, Part } from "./sdk"
+
+export const DEMO_SESSION_ID = "demo-session"
+
+const USER_MESSAGE_ID = "demo-msg-user"
+const ASSISTANT_MESSAGE_ID = "demo-msg-assistant"
+const COMPLETION_MESSAGE_ID = "demo-msg-completion"
+const DENIAL_MESSAGE_ID = "demo-msg-denial"
+
+export interface DemoPermission {
+ id: string
+ permission: string
+ patterns: string[]
+}
+
+export interface DemoScript {
+ messages: [Message, Message]
+ parts: Record
+ permission: DemoPermission
+}
+
+/**
+ * Builds the scripted user->assistant exchange shown on first render:
+ * a user bug report, brief assistant reasoning, a search tool call, and an
+ * edit tool call whose diff renders via ToolCallCard -> EditDetail ->
+ * DiffView once expanded. `now` is injectable for deterministic tests.
+ */
+export function buildDemoScript(now: number = Date.now()): DemoScript {
+ const userMessage: Message = {
+ id: USER_MESSAGE_ID,
+ sessionID: DEMO_SESSION_ID,
+ role: "user",
+ time: { created: now - 9000 },
+ }
+
+ const assistantMessage: Message = {
+ id: ASSISTANT_MESSAGE_ID,
+ sessionID: DEMO_SESSION_ID,
+ role: "assistant",
+ time: { created: now - 8000, completed: now - 1000 },
+ modelID: "claude-opus-4-6",
+ providerID: "anthropic",
+ tokens: { input: 812, output: 246 },
+ cost: 0.0143,
+ }
+
+ const parts: Record = {
+ [USER_MESSAGE_ID]: [
+ {
+ id: "demo-part-user-text",
+ messageID: USER_MESSAGE_ID,
+ type: "text",
+ text: "The login button doesn't respond on Android when the keyboard is open — can you fix it?",
+ },
+ ],
+ [ASSISTANT_MESSAGE_ID]: [
+ {
+ id: "demo-part-reasoning",
+ messageID: ASSISTANT_MESSAGE_ID,
+ type: "reasoning",
+ text: "The tap handler on the login button isn't firing while the Android keyboard is open — likely the surrounding scroll view is swallowing the first tap to dismiss the keyboard instead of forwarding it to the button. Let me check the handler and confirm.",
+ },
+ {
+ id: "demo-part-text-search",
+ messageID: ASSISTANT_MESSAGE_ID,
+ type: "text",
+ text: "Found it — keyboardShouldPersistTaps isn't set on the form's scroll view, so Android eats the first tap. Searching for the file...",
+ },
+ {
+ id: "demo-part-tool-search",
+ messageID: ASSISTANT_MESSAGE_ID,
+ type: "tool",
+ tool: "grep",
+ callID: "demo-call-search",
+ state: {
+ status: "completed",
+ title: "Search: onPress in LoginButton",
+ input: { pattern: "onPress", path: "src/components/LoginButton.tsx" },
+ output: "12: onPress={handleLogin}",
+ time: { start: now - 6500, end: now - 6200 },
+ },
+ },
+ {
+ id: "demo-part-text-edit",
+ messageID: ASSISTANT_MESSAGE_ID,
+ type: "text",
+ text: 'Adding keyboardShouldPersistTaps="handled" so taps reach the button even while the keyboard is up.',
+ },
+ {
+ id: "demo-part-tool-edit",
+ messageID: ASSISTANT_MESSAGE_ID,
+ type: "tool",
+ tool: "edit",
+ callID: "demo-call-edit",
+ state: {
+ status: "completed",
+ title: "Edit src/components/LoginButton.tsx",
+ input: {
+ filePath: "src/components/LoginButton.tsx",
+ oldString: "",
+ newString: '',
+ },
+ time: { start: now - 5000, end: now - 4700 },
+ },
+ },
+ ],
+ }
+
+ const permission: DemoPermission = {
+ id: "demo-permission-1",
+ permission: "bash",
+ patterns: ["npm test -- LoginButton"],
+ }
+
+ return { messages: [userMessage, assistantMessage], parts, permission }
+}
+
+/** Follow-up assistant message shown after the demo permission is allowed. */
+export function buildDemoCompletionMessage(now: number = Date.now()): { message: Message; parts: Part[] } {
+ return {
+ message: {
+ id: COMPLETION_MESSAGE_ID,
+ sessionID: DEMO_SESSION_ID,
+ role: "assistant",
+ time: { created: now, completed: now },
+ modelID: "claude-opus-4-6",
+ providerID: "anthropic",
+ },
+ parts: [
+ {
+ id: "demo-part-completion",
+ messageID: COMPLETION_MESSAGE_ID,
+ type: "text",
+ text: "Tests passed — the login button now responds correctly with the keyboard open. Try it yourself with your own server.",
+ },
+ ],
+ }
+}
+
+/** Follow-up assistant message shown after the demo permission is denied. */
+export function buildDemoDenialMessage(now: number = Date.now()): { message: Message; parts: Part[] } {
+ return {
+ message: {
+ id: DENIAL_MESSAGE_ID,
+ sessionID: DEMO_SESSION_ID,
+ role: "assistant",
+ time: { created: now, completed: now },
+ modelID: "claude-opus-4-6",
+ providerID: "anthropic",
+ },
+ parts: [
+ {
+ id: "demo-part-denial",
+ messageID: DENIAL_MESSAGE_ID,
+ type: "text",
+ text: "No changes were made — permission was denied. You're always in control on your own server too.",
+ },
+ ],
+ }
+}
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json
index e2e305b..02745dd 100644
--- a/src/lib/i18n/en.json
+++ b/src/lib/i18n/en.json
@@ -259,6 +259,7 @@
"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",
+ "tryDemoButton": "Try a demo (no server needed)",
"authFailedTitle": "Authentication Failed",
"authFailedSubtitle": "{{name}} rejected your credentials. Check the username and password to reconnect.",
"checkCredentialsButton": "Check Credentials",
@@ -424,5 +425,13 @@
"daysAgo": "{{count}}d ago"
}
}
+ },
+ "demo": {
+ "title": "Demo",
+ "banner": "Demo — sample conversation, no server connected",
+ "ctaTitle": "Like what you see?",
+ "ctaSubtitle": "This was a scripted demo. Connect your own opencode server to do this for real.",
+ "connectButton": "Connect your own server",
+ "setupGuideLink": "How to set up a server"
}
}
diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json
index a735555..14b0f71 100644
--- a/src/lib/i18n/zh-Hans.json
+++ b/src/lib/i18n/zh-Hans.json
@@ -259,6 +259,7 @@
"noConnectionSubtitle": "连接到运行 opencode serve 的电脑(同一网络或通过 Tailscale)以开始使用。",
"setupGuideLink": "如何设置服务器",
"addConnectionButton": "添加连接",
+ "tryDemoButton": "试用演示(无需服务器)",
"authFailedTitle": "身份验证失败",
"authFailedSubtitle": "{{name}} 拒绝了您的凭据。请检查用户名和密码后重新连接。",
"checkCredentialsButton": "检查凭据",
@@ -424,5 +425,13 @@
"daysAgo": "{{count}} 天前"
}
}
+ },
+ "demo": {
+ "title": "演示",
+ "banner": "演示 — 示例对话,未连接服务器",
+ "ctaTitle": "喜欢这个体验吗?",
+ "ctaSubtitle": "这是一个脚本化的演示。连接您自己的 opencode 服务器即可真正体验。",
+ "connectButton": "连接您自己的服务器",
+ "setupGuideLink": "如何设置服务器"
}
}