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:
94
.maestro/flows/demo.yaml
Normal file
94
.maestro/flows/demo.yaml
Normal 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
|
||||
@@ -444,6 +444,18 @@ export default function SessionsScreen() {
|
||||
>
|
||||
<Text style={styles.setupGuideLinkText}>{t("sessionsList.empty.setupGuideLink")}</Text>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
124
app/demo.tsx
Normal file
124
app/demo.tsx
Normal 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" },
|
||||
})
|
||||
@@ -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 =="
|
||||
|
||||
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