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

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

View File

@@ -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": "如何设置服务器"
}
}