feat(demo): add offline demo mode for zero-server activation (#108)

Installers with no self-hosted opencode server hit a dead end at the
empty Sessions state, contributing to ~0% 7-day retention. Adds a
fully offline, scripted /demo route reusing the real chat components
(MessageBubble, ToolCallCard/DiffView, PermissionPrompt) so new users
can see what opencode does before connecting anything, then funnels
them to Connect / the setup guide.

- src/lib/demo-script.ts: pure, hardcoded Message/Part fixture builder
  (no RN/store/network imports) — the isolation guarantee.
- app/demo.tsx: new /demo route rendering the scripted conversation
  via useMemo'd local state only; permission reply is local setState,
  never sessionClient.permission.reply().
- app/(tabs)/index.tsx: "Try a demo" button added to the no-connection
  empty state, placed after the existing add-connection-button so its
  position/testID for existing Maestro flows is unchanged.
- .maestro/flows/demo.yaml: new E2E flow covering the empty-state CTA
  through conversation, diff expand, permission approve, and the CTA
  reaching the real connect form.
- scripts/run-e2e-flows.sh: registers demo in NEWER_FLOWS (non-blocking)
  so it actually runs in CI.
- i18n: new sessionsList.empty.tryDemoButton and demo.* keys added to
  both en.json and zh-Hans.json (catalog-parity verified).

npm run typecheck: clean. npm test: 175/175 passing.

Co-authored-by: engineer <engineer@macbookpro.lan>
This commit is contained in:
Den
2026-07-17 19:04:58 -07:00
committed by GitHub
parent dfc38b3276
commit c9ec92d8b7
8 changed files with 514 additions and 1 deletions

94
.maestro/flows/demo.yaml Normal file
View File

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

View File

@@ -444,6 +444,18 @@ export default function SessionsScreen() {
> >
<Text style={styles.setupGuideLinkText}>{t("sessionsList.empty.setupGuideLink")}</Text> <Text style={styles.setupGuideLinkText}>{t("sessionsList.empty.setupGuideLink")}</Text>
</TouchableOpacity> </TouchableOpacity>
{/* No-server activation path (retention): a fully offline scripted
demo, isolated from real connect/session state — see app/demo.tsx. */}
<TouchableOpacity
style={[styles.tryDemoButton, isDark && styles.tryDemoButtonDark]}
onPress={() => router.push("/demo")}
testID="try-demo-button"
>
<Ionicons name="play-circle-outline" size={16} color={isDark ? "#a78bfa" : "#6d28d9"} />
<Text style={[styles.tryDemoButtonText, isDark && styles.tryDemoButtonTextDark]}>
{t("sessionsList.empty.tryDemoButton")}
</Text>
</TouchableOpacity>
</View> </View>
) )
} }
@@ -1030,6 +1042,28 @@ const styles = StyleSheet.create({
fontWeight: "600", fontWeight: "600",
color: "#6366f1", 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: { loadingContainer: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: "center",

124
app/demo.tsx Normal file
View File

@@ -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 (
<>
<Stack.Screen options={{ title: t("demo.title"), presentation: "card" }} />
<View style={[s.container, isDark && s.containerDark]} testID="demo-screen">
<View style={[s.banner, isDark && s.bannerDark]} testID="demo-banner">
<Ionicons name="play-circle-outline" size={16} color="#8b5cf6" />
<Text style={s.bannerText}>{t("demo.banner")}</Text>
</View>
<ScrollView contentContainerStyle={s.scrollContent} keyboardShouldPersistTaps="handled">
<MessageBubble message={userMessage} parts={script.parts[userMessage.id] || []} isDark={isDark} />
<MessageBubble message={assistantMessage} parts={script.parts[assistantMessage.id] || []} isDark={isDark} />
{!reply && <PermissionPrompt permission={script.permission} isDark={isDark} onReply={handleReply} />}
{completion && (
<MessageBubble message={completion.message} parts={completion.parts} isDark={isDark} />
)}
<View style={[s.ctaCard, isDark && s.ctaCardDark]} testID="demo-cta-card">
<Text style={[s.ctaTitle, isDark && s.textWhite]}>{t("demo.ctaTitle")}</Text>
<Text style={[s.ctaSubtitle, isDark && s.metaDark]}>{t("demo.ctaSubtitle")}</Text>
<TouchableOpacity
style={s.connectButton}
onPress={() => router.push("/connection/add")}
testID="demo-connect-button"
>
<Text style={s.connectButtonText}>{t("demo.connectButton")}</Text>
</TouchableOpacity>
<TouchableOpacity
style={s.setupGuideLink}
onPress={() => Linking.openURL(SETUP_GUIDE_URL)}
testID="demo-setup-guide-link"
>
<Text style={s.setupGuideLinkText}>{t("demo.setupGuideLink")}</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
</>
)
}
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" },
})

View File

@@ -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 # selectors/seeding surface) and moving each back into CORE once confirmed
# green. # green.
CORE_FLOWS=(activation-positive activation-negative-401) 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" mkdir -p "$ROOT/artifacts/screenshots" "$ROOT/artifacts/diag"
echo "== installing APK ==" echo "== installing APK =="

View File

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

167
src/lib/demo-script.ts Normal file
View File

@@ -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<string, Part[]>
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<string, Part[]> = {
[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: "<ScrollView contentContainerStyle={styles.form}>",
newString: '<ScrollView contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">',
},
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.",
},
],
}
}

View File

@@ -259,6 +259,7 @@
"noConnectionSubtitle": "Connect to a computer running opencode serve on your network or via Tailscale 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", "setupGuideLink": "How to set up a server",
"addConnectionButton": "Add Connection", "addConnectionButton": "Add Connection",
"tryDemoButton": "Try a demo (no server needed)",
"authFailedTitle": "Authentication Failed", "authFailedTitle": "Authentication Failed",
"authFailedSubtitle": "{{name}} rejected your credentials. Check the username and password to reconnect.", "authFailedSubtitle": "{{name}} rejected your credentials. Check the username and password to reconnect.",
"checkCredentialsButton": "Check Credentials", "checkCredentialsButton": "Check Credentials",
@@ -424,5 +425,13 @@
"daysAgo": "{{count}}d ago" "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"
} }
} }

View File

@@ -259,6 +259,7 @@
"noConnectionSubtitle": "连接到运行 opencode serve 的电脑(同一网络或通过 Tailscale)以开始使用。", "noConnectionSubtitle": "连接到运行 opencode serve 的电脑(同一网络或通过 Tailscale)以开始使用。",
"setupGuideLink": "如何设置服务器", "setupGuideLink": "如何设置服务器",
"addConnectionButton": "添加连接", "addConnectionButton": "添加连接",
"tryDemoButton": "试用演示(无需服务器)",
"authFailedTitle": "身份验证失败", "authFailedTitle": "身份验证失败",
"authFailedSubtitle": "{{name}} 拒绝了您的凭据。请检查用户名和密码后重新连接。", "authFailedSubtitle": "{{name}} 拒绝了您的凭据。请检查用户名和密码后重新连接。",
"checkCredentialsButton": "检查凭据", "checkCredentialsButton": "检查凭据",
@@ -424,5 +425,13 @@
"daysAgo": "{{count}} 天前" "daysAgo": "{{count}} 天前"
} }
} }
},
"demo": {
"title": "演示",
"banner": "演示 — 示例对话,未连接服务器",
"ctaTitle": "喜欢这个体验吗?",
"ctaSubtitle": "这是一个脚本化的演示。连接您自己的 opencode 服务器即可真正体验。",
"connectButton": "连接您自己的服务器",
"setupGuideLink": "如何设置服务器"
} }
} }