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:
74
src/lib/demo-script.test.ts
Normal file
74
src/lib/demo-script.test.ts
Normal 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
167
src/lib/demo-script.ts
Normal 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.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "如何设置服务器"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user