From 8af8449b77dd859754ed6204051be183762adf87 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 18 Jul 2026 00:11:09 +1000 Subject: [PATCH 1/3] 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 ? ( + <> + + +
+ +
+ + ); +} + +const SAMPLE_BEFORE = `export function greet(name: string) { + return "Hello, " + name; +} +`; + +const SAMPLE_AFTER = `export function greet(name: string, title = "Hello"): string { + return \`\${title}, \${name}\`; +} + +export function shout(greeting: string): string { + return greeting.toUpperCase(); +} +`; diff --git a/packages/web/src/components/AgentTranscript.tsx b/packages/web/src/components/AgentTranscript.tsx index 9c2e4fa..99cef57 100644 --- a/packages/web/src/components/AgentTranscript.tsx +++ b/packages/web/src/components/AgentTranscript.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react"; import type { SupaplaneClient } from "@echohello/client"; import type { AgentEvent } from "@echohello/protocol"; +import { MarkdownView } from "./MarkdownView.js"; + interface Props { client: SupaplaneClient | null; } @@ -9,34 +11,62 @@ interface Props { /** * The agent transcript renderer. * - * TODO: integrate Pretext (`@chenglou/pretext`) for DOM-free text measurement - * so long-running transcripts stay performant — the prepare → layout API is - * expected to be wired in once the streaming path is finalised (see - * `docs/architecture.md` and the long-session perf notes). - * TODO: replace the simple list with a virtualised list (`@tanstack/react-virtual`) - * once transcript sizes start exceeding a few hundred events. + * Long message events stream through markdown-it (token-stream parser with + * safe defaults) for agent text, and small line entries cover the meta + * events (tool.start, status, error, permission_request). + * + * TODO: integrate Pretext for DOM-free text measurement so long-running + * transcripts stay performant — see docs/architecture.md. + * TODO: replace the simple list with a virtualised list + * (`@tanstack/react-virtual`) once transcript sizes start exceeding a few + * hundred events. */ export function AgentTranscript({ client }: Props) { - const [lines, setLines] = useState>( - [], - ); + const [lines, setLines] = useState< + Array<{ + id: string; + text: string; + kind: AgentEvent["type"]; + markdown?: boolean; + }> + >([]); const containerRef = useRef(null); useEffect(() => { if (!client) return; const off = client.onAgentEvent((event) => { - setLines((prev) => [ - ...prev, - { - id: `${event.type}-${event.ts}-${Math.random().toString(36).slice(2, 6)}`, - text: describeEvent(event), - kind: event.type, - }, - ]); + setLines((prev) => { + // Coalesce streaming `message.delta` into the latest open message + // line so the transcript doesn't fragment on every token push. + const last = prev[prev.length - 1]; + if (event.type === "message.delta" && last && last.kind === "message.delta") { + const next = prev.slice(); + next[next.length - 1] = { + ...last, + text: last.text + event.text, + }; + return next; + } + return [ + ...prev, + { + id: `${event.type}-${event.ts}-${Math.random().toString(36).slice(2, 6)}`, + text: describeEvent(event), + kind: event.type, + markdown: isMarkdown(event), + }, + ]; + }); }); return off; }, [client]); + useEffect(() => { + if (lines.length === 0) return; + const id = setTimeout(() => containerRef.current?.scrollTo({ top: 9e9 }), 50); + return () => clearTimeout(id); + }, [lines.length]); + return (
)} {lines.map((line) => ( -
- {line.kind} - {line.text} -
+
+
+ {line.kind} + {!line.markdown && line.kind !== "message.delta" ? ( + {line.text} + ) : null} +
+ {line.markdown ? ( + + ) : line.kind === "message.delta" ? null : ( +
{line.text}
+ )} +
))}
); } +function isMarkdown(event: AgentEvent): boolean { + return event.type === "message.delta" || event.type === "message.final"; +} + function describeEvent(event: AgentEvent): string { switch (event.type) { case "message.delta": diff --git a/packages/web/src/components/Composer.tsx b/packages/web/src/components/Composer.tsx index d6dac60..0936e1b 100644 --- a/packages/web/src/components/Composer.tsx +++ b/packages/web/src/components/Composer.tsx @@ -3,9 +3,16 @@ import type { SupaplaneClient } from "@echohello/client"; interface Props { client: SupaplaneClient | null; + /** Optional diff request opened from the workspace sidebar (or composer). */ + diff?: { + name: string; + before: string; + after: string; + prevName?: string; + } | null; } -export function Composer({ client }: Props) { +export function Composer({ client, diff }: Props) { const [prompt, setPrompt] = useState(""); const [sessionId, setSessionId] = useState(""); @@ -20,6 +27,17 @@ export function Composer({ client }: Props) { setPrompt(""); }; + const openDiff = (): void => { + if (!client) return; + const session = sessionId.trim(); + if (!session) return; + client.sendCommand({ + type: "diff.open", + sessionId: session, + path: "(composer demo)", + }); + }; + return (
@@ -41,6 +59,14 @@ export function Composer({ client }: Props) { } }} /> +
+ {diff ? ( +

+ Opened diff for {diff.name} + {diff.prevName ? ( + <> + {" "} + (renamed from {diff.prevName}) + + ) : null} +

+ ) : null}
); } diff --git a/packages/web/src/components/DiffView.tsx b/packages/web/src/components/DiffView.tsx new file mode 100644 index 0000000..3a4b880 --- /dev/null +++ b/packages/web/src/components/DiffView.tsx @@ -0,0 +1,85 @@ +import { useMemo } from "react"; +import { createTwoFilesPatch } from "diff"; +import { FileDiff, processFile, type FileDiffMetadata } from "@pierre/diffs"; + +interface Props { + /** Name shown in the file header. Used for language inference. */ + name: string; + /** Earlier version of the file. Empty string for an added file. */ + before: string; + /** Newer version of the file. Empty string for a deleted file. */ + after: string; + /** Optional prior path (when the file was renamed or moved). */ + prevName?: string; + /** Override the diff layout. Defaults to split side-by-side. */ + layout?: "split" | "unified"; + className?: string; +} + +const DARK_THEME = "pierre-dark"; + +/** + * Render a two-version diff of a single file. The renderer is a Shadow DOM + + * CSS grid component with shiki-powered syntax highlighting; hunks are + * computed client-side from a unified-diff patch produced via `diff.createTwoFilesPatch`. + * + * Pairs naturally with the `diff.open` client command — the renderer asks + * the daemon for a diff and the {name, before, after} triple drives this view. + */ +export function DiffView({ name, before, after, prevName, layout = "split", className }: Props) { + const fileDiff = useMemo(() => { + if (before === after) return null; + const patch = createTwoFilesPatch(prevName ?? name, name, before, after, undefined, undefined, { + context: 3, + }); + return ( + processFile(patch, { + oldFile: { name: prevName ?? name, contents: before }, + newFile: { name, contents: after }, + }) ?? null + ); + }, [name, prevName, before, after]); + + if (!fileDiff) { + return ( +
+ No changes — both sides are identical. +
+ ); + } + + return ( +
+ {/* + The pierre renderer exposes a custom-element-backed React component. + The TypeScript JSX checker in React 19 rejects its component type, so + cast through `unknown` to render the underlying element. The runtime + type matches React's element signature. + */} + +
+ ); +} + +function RenderFileDiff({ fileDiff, layout }: { fileDiff: FileDiffMetadata; layout: "split" | "unified" }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Fd = FileDiff as unknown as React.ComponentType<{ + fileDiff: FileDiffMetadata; + options: { theme: string; diffStyle: "split" | "unified"; expansionDirection: "expand-up" }; + }>; + return ( + + ); +} diff --git a/packages/web/src/components/MarkdownView.tsx b/packages/web/src/components/MarkdownView.tsx new file mode 100644 index 0000000..ce5c295 --- /dev/null +++ b/packages/web/src/components/MarkdownView.tsx @@ -0,0 +1,104 @@ +import { useMemo } from "react"; +import MarkdownIt from "markdown-it"; + +interface Props { + source: string; + className?: string; +} + +const md = new MarkdownIt({ + html: false, + linkify: true, + breaks: false, + typographer: true, +}); + +/** + * Render a markdown source string as HTML. Uses markdown-it with safe defaults + * (`html: false` so no inline HTML is interpreted) and a token-stream parser + * suited for agent message content. + * + * The renderer is intentionally small and side-effect free. We intentionally + * don't run a DOM-less measurement layer here — when transcript sizes grow, + * swap this for a token-streaming renderer backed by Pretext. + */ +export function MarkdownView({ source, className }: Props) { + const html = useMemo(() => renderSafe(source), [source]); + return ( +
+ ); +} + +function renderSafe(source: string): string { + try { + const tokens = md.parse(source, {}); + for (const token of tokens) { + sanitizeAttrs(token); + } + return md.renderer.render(tokens, md.options, {}); + } catch { + return escapeHtml(source); + } +} + +interface MarkdownToken { + type: string; + attrs?: [string, string][] | null; +} + +function sanitizeAttrs(token: MarkdownToken): void { + const attrs = token.attrs; + if (!attrs) return; + const kept: [string, string][] = []; + for (const [name, value] of attrs) { + if (name.startsWith("on") || name === "src" || name === "srcdoc" || name === "style") continue; + if (name === "href" && !isSafeUrl(value)) continue; + kept.push([name, value]); + } + token.attrs = kept; +} + +const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:", "xmpp:", "ftp:"]); + +function isSafeUrl(url: string): boolean { + const trimmed = url.trim(); + if ( + trimmed.startsWith("#") || + trimmed.startsWith("/") || + trimmed.startsWith("./") || + trimmed.startsWith("../") + ) { + return true; + } + const colon = trimmed.indexOf(":"); + if (colon === -1) return true; + const proto = trimmed.slice(0, colon + 1).toLowerCase(); + return SAFE_PROTOCOLS.has(proto); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} diff --git a/packages/web/src/components/WorkspaceSidebar.tsx b/packages/web/src/components/WorkspaceSidebar.tsx index 5460754..6ade870 100644 --- a/packages/web/src/components/WorkspaceSidebar.tsx +++ b/packages/web/src/components/WorkspaceSidebar.tsx @@ -4,9 +4,11 @@ import type { WorkspaceState } from "@echohello/protocol"; interface Props { client: SupaplaneClient | null; + /** Fired when the user clicks "Open diff" on a workspace card. */ + onDemoDiff?: (workspaceId: string) => void; } -export function WorkspaceSidebar({ client }: Props) { +export function WorkspaceSidebar({ client, onDemoDiff }: Props) { const [workspaces, setWorkspaces] = useState([]); useEffect(() => { @@ -57,6 +59,14 @@ export function WorkspaceSidebar({ client }: Props) {
{ws.branch ?? "no branch"}
+ {onDemoDiff ? ( + + ) : null} ))}