From c9ec92d8b723b3658b70f52882a5d7ef083271a6 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:04:58 -0700 Subject: [PATCH] feat(demo): add offline demo mode for zero-server activation (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .maestro/flows/demo.yaml | 94 ++++++++++++++++++++ app/(tabs)/index.tsx | 34 ++++++++ app/demo.tsx | 124 ++++++++++++++++++++++++++ scripts/run-e2e-flows.sh | 4 +- src/lib/demo-script.test.ts | 74 ++++++++++++++++ src/lib/demo-script.ts | 167 ++++++++++++++++++++++++++++++++++++ src/lib/i18n/en.json | 9 ++ src/lib/i18n/zh-Hans.json | 9 ++ 8 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 .maestro/flows/demo.yaml create mode 100644 app/demo.tsx create mode 100644 src/lib/demo-script.test.ts create mode 100644 src/lib/demo-script.ts 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": "如何设置服务器" } }