From 8af8449b77dd859754ed6204051be183762adf87 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 18 Jul 2026 00:11:09 +1000 Subject: [PATCH 1/4] feat(app): scaffold mobile UI for connect/sessions/transcript Adds the three-screen MVP on top of @echohello/client: - connect: manual ws:// endpoint + label, saved to AsyncStorage, connects via SupaplaneClient - sessions: workspace list grouped by freshness, tap to drill in - session: transcript + composer (send/abort/start) bound to ClientCommand wire types Shared infrastructure: - zustand connection-store: single SupaplaneClient instance, status, workspaces, sessions, per-session event buffer - AsyncStorage wrapper (v3 API: setMany/removeMany) for the saved endpoint and TOFU-pinned server fingerprint - dark theme tokens, Button, TextField, ConnectionBanner Bundler plumbing: metro.config.cjs with explicit watchFolders and nodeModulesPaths so Bun's hoisting doesn't trip the resolver on a monorepo root. Co-authored-by: opencode --- packages/app/app/_layout.tsx | 45 ++- packages/app/app/connect.tsx | 125 +++++++ packages/app/app/index.tsx | 48 +-- packages/app/app/session/[id].tsx | 311 ++++++++++++++++++ packages/app/app/sessions.tsx | 170 ++++++++++ packages/app/expo-env.d.ts | 1 - packages/app/metro.config.cjs | 20 ++ packages/app/package.json | 3 +- packages/app/src/components/Button.tsx | 66 ++++ .../app/src/components/ConnectionBanner.tsx | 59 ++++ packages/app/src/components/TextField.tsx | 45 +++ packages/app/src/state/connection-store.ts | 216 ++++++++++++ packages/app/src/storage.ts | 52 +++ packages/app/src/theme.ts | 41 +++ packages/app/tsconfig.json | 29 +- packages/app/tsconfig.typecheck.json | 3 + 16 files changed, 1179 insertions(+), 55 deletions(-) create mode 100644 packages/app/app/connect.tsx create mode 100644 packages/app/app/session/[id].tsx create mode 100644 packages/app/app/sessions.tsx delete mode 100644 packages/app/expo-env.d.ts create mode 100644 packages/app/metro.config.cjs create mode 100644 packages/app/src/components/Button.tsx create mode 100644 packages/app/src/components/ConnectionBanner.tsx create mode 100644 packages/app/src/components/TextField.tsx create mode 100644 packages/app/src/state/connection-store.ts create mode 100644 packages/app/src/storage.ts create mode 100644 packages/app/src/theme.ts create mode 100644 packages/app/tsconfig.typecheck.json diff --git a/packages/app/app/_layout.tsx b/packages/app/app/_layout.tsx index fbf4426..06b0f8d 100644 --- a/packages/app/app/_layout.tsx +++ b/packages/app/app/_layout.tsx @@ -1,19 +1,48 @@ -import { Stack } from "expo-router"; +import { useEffect } from "react"; +import { Stack, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; + +import { useConnectionStore } from "../src/state/connection-store.js"; export default function RootLayout(): React.JSX.Element { + const router = useRouter(); + const hydrate = useConnectionStore((s) => s.hydrate); + const status = useConnectionStore((s) => s.status); + + useEffect(() => { + void hydrate(); + }, [hydrate]); + + useEffect(() => { + if (status === "idle" || status === "disconnected" || status === "error" || status === "exhausted") { + router.replace("/connect"); + } else if (status === "connected" || status === "connecting" || status === "reconnecting") { + router.replace("/sessions"); + } + }, [status, router]); + return ( - <> + + - + + + + - - + ); } + +const colors = { + bg: "#0a0a0f", + text: "#e5e5e5", +}; diff --git a/packages/app/app/connect.tsx b/packages/app/app/connect.tsx new file mode 100644 index 0000000..81aaa1c --- /dev/null +++ b/packages/app/app/connect.tsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from "react"; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useRouter } from "expo-router"; + +import { Button } from "../src/components/Button.js"; +import { ConnectionBanner } from "../src/components/ConnectionBanner.js"; +import { TextField } from "../src/components/TextField.js"; +import { useConnectionStore } from "../src/state/connection-store.js"; +import { colors, spacing, typography } from "../src/theme.js"; + +function isValidWsUrl(value: string): boolean { + return /^wss?:\/\/.+/i.test(value.trim()); +} + +export default function ConnectScreen() { + const router = useRouter(); + const status = useConnectionStore((s) => s.status); + const saved = useConnectionStore((s) => s.saved); + const error = useConnectionStore((s) => s.error); + const connect = useConnectionStore((s) => s.connect); + const disconnect = useConnectionStore((s) => s.disconnect); + + const [endpoint, setEndpoint] = useState(saved?.endpoint ?? "ws://"); + const [label, setLabel] = useState(saved?.label ?? ""); + const [validation, setValidation] = useState(null); + + const connecting = status === "connecting" || status === "reconnecting"; + const isConnected = status === "connected"; + + const helper = useMemo(() => { + if (isConnected) { + return "Connected. Use Sessions to pick a workspace, or Disconnect to pair a different daemon."; + } + return "Enter the WebSocket URL of your Supaplane daemon. QR pairing arrives once the relay ships."; + }, [isConnected]); + + const onConnect = async () => { + if (!isValidWsUrl(endpoint)) { + setValidation("Endpoint must start with ws:// or wss://"); + return; + } + setValidation(null); + const trimmedLabel = label.trim(); + await connect( + trimmedLabel ? { endpoint: endpoint.trim(), label: trimmedLabel } : { endpoint: endpoint.trim() }, + ); + }; + + return ( + + + + + + Supaplane + {helper} + + + + + + + + {error && status === "error" ? ( + Last attempt failed: {error} + ) : null} + + + {isConnected ? ( + <> +