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

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