From b20ee4ed413f74670635351795b75357b2d1b130 Mon Sep 17 00:00:00 2001 From: John Date: Thu, 28 May 2026 11:47:04 +0800 Subject: [PATCH 01/13] Add mobile PWA proof of concept Create a separate @crew44/mobile-pwa workspace with pairing, relay RPC, chat browsing, message sending, and PWA static build assets. Expose the desktop mobile pairing entry for debugging while keeping the existing Expo mobile app in place. --- package-lock.json | 79 +++ package.json | 5 + packages/mobile-pwa/.gitignore | 1 + packages/mobile-pwa/README.md | 61 ++ packages/mobile-pwa/index.html | 16 + packages/mobile-pwa/package.json | 29 + packages/mobile-pwa/public/icons/icon-192.png | Bin 0 -> 36516 bytes packages/mobile-pwa/public/icons/icon-512.png | Bin 0 -> 12822 bytes .../mobile-pwa/public/manifest.webmanifest | 22 + packages/mobile-pwa/public/sw.js | 27 + packages/mobile-pwa/src/App.tsx | 61 ++ .../mobile-pwa/src/__tests__/bytes.test.ts | 13 + .../mobile-pwa/src/__tests__/events.test.ts | 131 ++++ .../mobile-pwa/src/__tests__/noise.test.ts | 33 + packages/mobile-pwa/src/api/client.ts | 138 +++++ packages/mobile-pwa/src/api/events.ts | 383 ++++++++++++ packages/mobile-pwa/src/api/types.ts | 104 ++++ .../src/client/MobileClientProvider.tsx | 310 ++++++++++ packages/mobile-pwa/src/main.tsx | 19 + packages/mobile-pwa/src/pages/AgentPage.tsx | 77 +++ packages/mobile-pwa/src/pages/AgentsPage.tsx | 65 ++ packages/mobile-pwa/src/pages/ChatPage.tsx | 250 ++++++++ packages/mobile-pwa/src/pages/HomePage.tsx | 88 +++ packages/mobile-pwa/src/pages/PairPage.tsx | 90 +++ packages/mobile-pwa/src/pages/ProjectPage.tsx | 170 +++++ packages/mobile-pwa/src/remote/bytes.ts | 70 +++ packages/mobile-pwa/src/remote/client.ts | 144 +++++ .../mobile-pwa/src/remote/frameTransport.ts | 29 + packages/mobile-pwa/src/remote/noise.ts | 188 ++++++ .../mobile-pwa/src/remote/pairingOffer.ts | 54 ++ packages/mobile-pwa/src/remote/relay.ts | 170 +++++ packages/mobile-pwa/src/remote/rpc.ts | 97 +++ .../mobile-pwa/src/storage/pairingStore.ts | 85 +++ packages/mobile-pwa/src/styles.css | 582 ++++++++++++++++++ packages/mobile-pwa/src/ui/Screen.tsx | 125 ++++ packages/mobile-pwa/src/ui/Timeline.tsx | 122 ++++ packages/mobile-pwa/src/ui/icons.tsx | 43 ++ packages/mobile-pwa/src/vite-env.d.ts | 1 + packages/mobile-pwa/tsconfig.json | 24 + packages/mobile-pwa/vite.config.ts | 22 + src/Sidebar.jsx | 3 +- src/__tests__/sidebar-actions.test.jsx | 14 +- 42 files changed, 3941 insertions(+), 4 deletions(-) create mode 100644 packages/mobile-pwa/.gitignore create mode 100644 packages/mobile-pwa/README.md create mode 100644 packages/mobile-pwa/index.html create mode 100644 packages/mobile-pwa/package.json create mode 100644 packages/mobile-pwa/public/icons/icon-192.png create mode 100644 packages/mobile-pwa/public/icons/icon-512.png create mode 100644 packages/mobile-pwa/public/manifest.webmanifest create mode 100644 packages/mobile-pwa/public/sw.js create mode 100644 packages/mobile-pwa/src/App.tsx create mode 100644 packages/mobile-pwa/src/__tests__/bytes.test.ts create mode 100644 packages/mobile-pwa/src/__tests__/events.test.ts create mode 100644 packages/mobile-pwa/src/__tests__/noise.test.ts create mode 100644 packages/mobile-pwa/src/api/client.ts create mode 100644 packages/mobile-pwa/src/api/events.ts create mode 100644 packages/mobile-pwa/src/api/types.ts create mode 100644 packages/mobile-pwa/src/client/MobileClientProvider.tsx create mode 100644 packages/mobile-pwa/src/main.tsx create mode 100644 packages/mobile-pwa/src/pages/AgentPage.tsx create mode 100644 packages/mobile-pwa/src/pages/AgentsPage.tsx create mode 100644 packages/mobile-pwa/src/pages/ChatPage.tsx create mode 100644 packages/mobile-pwa/src/pages/HomePage.tsx create mode 100644 packages/mobile-pwa/src/pages/PairPage.tsx create mode 100644 packages/mobile-pwa/src/pages/ProjectPage.tsx create mode 100644 packages/mobile-pwa/src/remote/bytes.ts create mode 100644 packages/mobile-pwa/src/remote/client.ts create mode 100644 packages/mobile-pwa/src/remote/frameTransport.ts create mode 100644 packages/mobile-pwa/src/remote/noise.ts create mode 100644 packages/mobile-pwa/src/remote/pairingOffer.ts create mode 100644 packages/mobile-pwa/src/remote/relay.ts create mode 100644 packages/mobile-pwa/src/remote/rpc.ts create mode 100644 packages/mobile-pwa/src/storage/pairingStore.ts create mode 100644 packages/mobile-pwa/src/styles.css create mode 100644 packages/mobile-pwa/src/ui/Screen.tsx create mode 100644 packages/mobile-pwa/src/ui/Timeline.tsx create mode 100644 packages/mobile-pwa/src/ui/icons.tsx create mode 100644 packages/mobile-pwa/src/vite-env.d.ts create mode 100644 packages/mobile-pwa/tsconfig.json create mode 100644 packages/mobile-pwa/vite.config.ts diff --git a/package-lock.json b/package-lock.json index aaa0a49..1f13781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1579,6 +1579,10 @@ "resolved": "packages/mobile", "link": true }, + "node_modules/@crew44/mobile-pwa": { + "resolved": "packages/mobile-pwa", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -5824,6 +5828,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -6046,6 +6060,41 @@ "node": ">=10.0.0" } }, + "node_modules/@zxing/browser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz", + "integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.21.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -15677,6 +15726,16 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-deepmerge": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz", @@ -16666,6 +16725,26 @@ "vitest": "^4.1.6" } }, + "packages/mobile-pwa": { + "name": "@crew44/mobile-pwa", + "version": "0.1.0", + "dependencies": { + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@zxing/browser": "^0.1.5", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/react": "~19.1.10", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.9.2", + "vite": "^6.3.2", + "vitest": "^4.1.6" + } + }, "packages/mobile/node_modules/@expo/devtools": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", diff --git a/package.json b/package.json index edece6a..c590410 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,11 @@ "mobile:build:android": "npm --workspace=@crew44/mobile run build:android", "mobile:build:all": "npm --workspace=@crew44/mobile run build:all", "mobile:typecheck": "npm --workspace=@crew44/mobile run typecheck", + "mobile-pwa:start": "npm --workspace=@crew44/mobile-pwa run start --", + "mobile-pwa:build": "npm --workspace=@crew44/mobile-pwa run build", + "mobile-pwa:preview": "npm --workspace=@crew44/mobile-pwa run preview --", + "mobile-pwa:typecheck": "npm --workspace=@crew44/mobile-pwa run typecheck", + "test:mobile-pwa": "npm --workspace=@crew44/mobile-pwa run test", "test:web": "vitest run", "test:mobile": "npm --workspace=@crew44/mobile run test", "desktop:smoke:linux": "npm run build:daemon:linux && vite build && electron-builder --dir --linux --x64", diff --git a/packages/mobile-pwa/.gitignore b/packages/mobile-pwa/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/packages/mobile-pwa/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/mobile-pwa/README.md b/packages/mobile-pwa/README.md new file mode 100644 index 0000000..cf483fd --- /dev/null +++ b/packages/mobile-pwa/README.md @@ -0,0 +1,61 @@ +# Crew44 Mobile PWA + +POC mobile companion for Crew44. This package is a static Vite app intended for +`https://mobileapp.crew44.io`. + +## Development + +```bash +npm run mobile-pwa:start +``` + +## Build + +```bash +npm run mobile-pwa:build +``` + +The static output is written to: + +```text +packages/mobile-pwa/dist/ +``` + +## Nginx Static Site + +Point `mobileapp.crew44.io` at the build output directory and route SPA paths to +`index.html`: + +```nginx +server { + listen 443 ssl http2; + server_name mobileapp.crew44.io; + + root /var/www/crew44-mobile-pwa; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /sw.js { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location = /manifest.webmanifest { + add_header Cache-Control "public, max-age=3600"; + try_files $uri =404; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location /icons/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } +} +``` diff --git a/packages/mobile-pwa/index.html b/packages/mobile-pwa/index.html new file mode 100644 index 0000000..6c70aa5 --- /dev/null +++ b/packages/mobile-pwa/index.html @@ -0,0 +1,16 @@ + + + + + + + Crew44 Mobile + + + + + +
+ + + diff --git a/packages/mobile-pwa/package.json b/packages/mobile-pwa/package.json new file mode 100644 index 0000000..c9f02d4 --- /dev/null +++ b/packages/mobile-pwa/package.json @@ -0,0 +1,29 @@ +{ + "name": "@crew44/mobile-pwa", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "vite --host 0.0.0.0", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@zxing/browser": "^0.1.5", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/react": "~19.1.10", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.9.2", + "vite": "^6.3.2", + "vitest": "^4.1.6" + } +} diff --git a/packages/mobile-pwa/public/icons/icon-192.png b/packages/mobile-pwa/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..96fbb21c96393f1175014c5503d4271329458bd6 GIT binary patch literal 36516 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 literal 0 HcmV?d00001 diff --git a/packages/mobile-pwa/public/manifest.webmanifest b/packages/mobile-pwa/public/manifest.webmanifest new file mode 100644 index 0000000..76c6466 --- /dev/null +++ b/packages/mobile-pwa/public/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Crew44 Mobile", + "short_name": "Crew44", + "description": "Mobile companion for Crew44 desktop.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#FAF5E8", + "theme_color": "#FAF5E8", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/packages/mobile-pwa/public/sw.js b/packages/mobile-pwa/public/sw.js new file mode 100644 index 0000000..a71b3b3 --- /dev/null +++ b/packages/mobile-pwa/public/sw.js @@ -0,0 +1,27 @@ +const CACHE_NAME = "crew44-mobile-pwa-v1"; +const STATIC_ASSETS = [ + "/", + "/manifest.webmanifest", + "/icons/icon-192.png", + "/icons/icon-512.png" +]; + +self.addEventListener("install", event => { + event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))); + self.skipWaiting(); +}); + +self.addEventListener("activate", event => { + event.waitUntil( + caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", event => { + const request = event.request; + if (request.method !== "GET") return; + event.respondWith( + caches.match(request).then(cached => cached || fetch(request)) + ); +}); diff --git a/packages/mobile-pwa/src/App.tsx b/packages/mobile-pwa/src/App.tsx new file mode 100644 index 0000000..df016c3 --- /dev/null +++ b/packages/mobile-pwa/src/App.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useMobileClient } from "@/client/MobileClientProvider"; +import { AgentPage } from "@/pages/AgentPage"; +import { AgentsPage } from "@/pages/AgentsPage"; +import { ChatPage } from "@/pages/ChatPage"; +import { HomePage } from "@/pages/HomePage"; +import { PairPage } from "@/pages/PairPage"; +import { ProjectPage } from "@/pages/ProjectPage"; +import { Header, LoadingState, Screen } from "@/ui/Screen"; + +function currentPath(): string { + return window.location.hash.replace(/^#/, "") || "/"; +} + +function useHashRoute() { + const [path, setPath] = React.useState(currentPath); + React.useEffect(() => { + const onHashChange = () => setPath(currentPath()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + const navigate = React.useCallback((nextPath: string) => { + window.location.hash = nextPath; + setPath(nextPath); + }, []); + return { path, navigate }; +} + +export default function App() { + const client = useMobileClient(); + const { path, navigate } = useHashRoute(); + + React.useEffect(() => { + if (client.status === "unpaired" && path !== "/pair") navigate("/pair"); + if (client.status === "online" && path === "/pair") navigate("/"); + }, [client.status, navigate, path]); + + if (client.status === "loading" || client.status === "connecting" || client.status === "reconnecting") { + return ( + +
+ + + ); + } + + if (client.status === "unpaired") return ; + if (path === "/pair") return ; + if (path === "/agents") return ; + + const projectMatch = path.match(/^\/projects\/([^/]+)$/); + if (projectMatch) return ; + + const chatMatch = path.match(/^\/chats\/([^/]+)$/); + if (chatMatch) return ; + + const agentMatch = path.match(/^\/agents\/([^/]+)$/); + if (agentMatch) return ; + + return ; +} diff --git a/packages/mobile-pwa/src/__tests__/bytes.test.ts b/packages/mobile-pwa/src/__tests__/bytes.test.ts new file mode 100644 index 0000000..37dda4b --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/bytes.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { base64RawDecode, base64RawEncode, equalBytes } from "../remote/bytes"; + +describe("RawStd base64 helpers", () => { + it("encodes without padding like Go base64.RawStdEncoding", () => { + expect(base64RawEncode(new Uint8Array([1, 2, 3, 4, 5]))).toBe("AQIDBAU"); + }); + + it("round-trips decoded bytes", () => { + const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]); + expect(equalBytes(base64RawDecode(base64RawEncode(bytes)), bytes)).toBe(true); + }); +}); diff --git a/packages/mobile-pwa/src/__tests__/events.test.ts b/packages/mobile-pwa/src/__tests__/events.test.ts new file mode 100644 index 0000000..b2d1700 --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/events.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { buildRenderableTimeline, mapBackendEvent, TimelineItem } from "../api/events"; + +describe("mapBackendEvent", () => { + it("maps user messages", () => { + expect(mapBackendEvent({ + seq: 1, + type: "message", + ts: "2026-05-13T11:00:00.000Z", + actor_agent_id: "agent_1", + message: { role: "user", content: "hello" } + })).toMatchObject({ kind: "message", role: "user", body: "hello", author: "__human__" }); + }); + + it("maps handover events", () => { + expect(mapBackendEvent({ + seq: 2, + type: "handover", + ts: "2026-05-13T11:00:00.000Z", + actor_agent_id: "agent_1", + actor_agent_name: "Aria", + handover: { subtype: "occurred", agent_id: "agent_2", agent_name: "Bex", note: "continue" } + })).toMatchObject({ + kind: "handover", + authorName: "Aria", + subtype: "occurred", + agent_id: "agent_1", + target_agent_id: "agent_2", + target_agent_name: "Bex", + note: "continue" + }); + }); + + it("maps errors without throwing", () => { + expect(mapBackendEvent({ + seq: 3, + type: "error", + ts: "bad-date", + actor_agent_id: "agent_1", + error: { code: "bad", message: "Something failed" } + })).toMatchObject({ kind: "error", message: "Something failed", time: "" }); + }); + + it("builds renderable handover dividers and folds thoughts into messages", () => { + const events = [ + { + kind: "thinking", + seq: 1, + _seq: 1, + author: "agent_1", + time: "11:00", + tsISO: "", + reasoning: "checking", + seconds: 0 + }, + { + kind: "message", + seq: 2, + _seq: 2, + author: "agent_1", + role: "assistant", + body: "done", + time: "11:01", + tsISO: "" + }, + { + kind: "handover", + seq: 3, + _seq: 3, + author: "agent_1", + authorName: "Aria", + time: "11:02", + tsISO: "", + subtype: "delegate", + agent_id: "agent_1", + target_agent_id: "agent_2", + target_agent_name: "Bex", + note: "continue" + } + ] satisfies TimelineItem[]; + + const rendered = buildRenderableTimeline(events); + expect(rendered[0]).toMatchObject({ kind: "message", _thought: { reasoning: "checking" } }); + expect(rendered[1]).toMatchObject({ kind: "handover_divider", from: "agent_1", fromName: "Aria", to: "agent_2", toName: "Bex" }); + }); + + it("marks consecutive agent tool calls as header continuations", () => { + const events = [ + { + kind: "message", + seq: 1, + _seq: 1, + author: "agent_1", + role: "assistant", + body: "I will inspect it.", + time: "11:00", + tsISO: "" + }, + { + kind: "tool", + seq: 2, + _seq: 2, + author: "agent_1", + callId: "call-1", + tool: "exec_command", + path: "ls", + input: { command: "ls" }, + result: "ok", + time: "11:00", + tsISO: "" + }, + { + kind: "tool", + seq: 3, + _seq: 3, + author: "agent_1", + callId: "call-2", + tool: "exec_command", + path: "pwd", + input: { command: "pwd" }, + result: "ok", + time: "11:00", + tsISO: "" + } + ] satisfies TimelineItem[]; + + const rendered = buildRenderableTimeline(events); + expect(rendered[0]).toMatchObject({ kind: "message", showHeader: true }); + expect(rendered[1]).toMatchObject({ kind: "tool_group", showHeader: false }); + }); +}); diff --git a/packages/mobile-pwa/src/__tests__/noise.test.ts b/packages/mobile-pwa/src/__tests__/noise.test.ts new file mode 100644 index 0000000..b745f45 --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/noise.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { base64RawDecode, base64RawEncode } from "../remote/bytes"; +import { generateDHKeyPair, NoiseInitiator, publicKeyFromPrivate } from "../remote/noise"; + +function sequence(start: number): Uint8Array { + return Uint8Array.from({ length: 32 }, (_, index) => start + index); +} + +describe("Noise client primitives", () => { + it("derives stable X25519 public keys from private keys", () => { + expect(base64RawEncode(publicKeyFromPrivate(sequence(1)))).toBe("B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9/AsrhtHHw"); + }); + + it("emits deterministic NK first message bytes", () => { + const remoteStatic = publicKeyFromPrivate(sequence(33)); + const initiator = new NoiseInitiator("NK", remoteStatic, { + randomBytes() { + return sequence(1); + } + }); + expect(base64RawEncode(initiator.writeMessageA())).toBe("B6N8vBQgk8i3VdwbEOhstCY3StFqqFPtC9/AsrhtHHySZ17HkuNCoJta7RZFzycD"); + }); + + it("generates keypairs from an injected random source", () => { + const key = generateDHKeyPair({ + randomBytes(length) { + return Uint8Array.from({ length }, (_, index) => 255 - index); + } + }); + expect(key.privateKey).toHaveLength(32); + expect(base64RawDecode(base64RawEncode(key.publicKey))).toHaveLength(32); + }); +}); diff --git a/packages/mobile-pwa/src/api/client.ts b/packages/mobile-pwa/src/api/client.ts new file mode 100644 index 0000000..10f2813 --- /dev/null +++ b/packages/mobile-pwa/src/api/client.ts @@ -0,0 +1,138 @@ +import { JsonRpcPeer } from "@/remote/rpc"; +import { Agent, BackendEvent, Chat, ChatIndexEntry, MessageAttachment, Project, ToolDetails } from "./types"; + +export class CrewApi { + constructor(private readonly rpc: JsonRpcPeer) {} + + async listProjects(): Promise { + const data = await this.rpc.call<{ items?: Project[] }>("projects.list"); + return data.items || []; + } + + async listAgents(): Promise { + const data = await this.rpc.call<{ items?: Agent[] }>("agents.list"); + return data.items || []; + } + + async listProjectChats(projectId: string, options: { limit?: number; offset?: number } = {}): Promise { + const data = await this.rpc.call<{ items?: ChatIndexEntry[] }>("projects.chats.list", { + id: projectId, + limit: options.limit, + offset: options.offset + }); + return data.items || []; + } + + async createChat(projectId: string, title: string, mainAgentId: string): Promise { + return this.rpc.call("chats.create", { + project_id: projectId, + title, + main_agent_id: mainAgentId + }); + } + + async getChat(id: string): Promise { + return this.rpc.call("chats.get", { id }); + } + + async listEvents(chatId: string, after = 0, options: { compactTools?: boolean } = {}): Promise { + const data = await this.rpc.call<{ events?: BackendEvent[] }>("chats.events.list", { + chat_id: chatId, + after, + compact_tools: Boolean(options.compactTools) + }); + return data.events || []; + } + + async getToolDetails(chatId: string, toolCallSeq: number): Promise { + return this.rpc.call("chats.tool.get", { + chat_id: chatId, + tool_call_seq: toolCallSeq + }); + } + + async postMessage(chatId: string, content: string, targetAgentId: string, attachments: MessageAttachment[] = []): Promise { + return this.rpc.call("chats.messages.post", { + id: chatId, + content, + target_agent_id: targetAgentId, + attachments + }); + } + + async interruptMessage(chatId: string, content: string, attachments: MessageAttachment[] = []): Promise { + return this.rpc.call("chats.messages.interrupt", { + id: chatId, + content, + attachments + }); + } + + async cancelPendingSteer(chatId: string, steerId: string): Promise { + return this.rpc.call("chats.messages.interrupt.cancel", { id: chatId, steer_id: steerId }); + } + + async deliverPendingSteers(chatId: string, steerIds: string[]): Promise { + return this.rpc.call("chats.messages.interrupt.deliver", { id: chatId, steer_ids: steerIds }); + } + + async cancelChat(chatId: string): Promise { + return this.rpc.call("chats.cancel", { id: chatId }); + } + + async deleteRemoteDevice(deviceId: string): Promise { + return this.rpc.call("remote.devices.delete", { device_id: deviceId }); + } + + subscribeChatEvents( + chatId: string, + after: number, + options: { compactTools?: boolean }, + onEvent: (event: BackendEvent) => void, + onDone: () => void, + onError: (err: Error) => void + ): () => void { + let disposed = false; + let subscriptionId = ""; + const cleanups = [ + this.rpc.on("chat.event", params => { + const body = params as { subscription_id?: string; chat_id?: string; event?: BackendEvent }; + if (subscriptionId ? body.subscription_id !== subscriptionId : body.chat_id !== chatId) return; + if (body.event) onEvent(body.event); + }), + this.rpc.on("chat.done", params => { + const body = params as { subscription_id?: string; chat_id?: string }; + if (subscriptionId ? body.subscription_id !== subscriptionId : body.chat_id !== chatId) return; + onDone(); + }), + this.rpc.on("chat.error", params => { + const body = params as { subscription_id?: string; chat_id?: string; message?: string }; + if (subscriptionId ? body.subscription_id !== subscriptionId : body.chat_id !== chatId) return; + onError(new Error(body.message || "Chat stream failed")); + }) + ]; + + this.rpc.call<{ subscription_id: string }>("chats.events.subscribe", { + chat_id: chatId, + after, + compact_tools: Boolean(options.compactTools) + }) + .then(result => { + subscriptionId = result.subscription_id; + if (disposed && subscriptionId) { + this.rpc.call("chats.events.unsubscribe", { subscription_id: subscriptionId }).catch(() => {}); + } + }) + .catch(err => { + if (!disposed) onError(err); + }); + + return () => { + disposed = true; + for (const cleanup of cleanups) cleanup(); + if (subscriptionId) { + this.rpc.call("chats.events.unsubscribe", { subscription_id: subscriptionId }).catch(() => {}); + } + }; + } +} diff --git a/packages/mobile-pwa/src/api/events.ts b/packages/mobile-pwa/src/api/events.ts new file mode 100644 index 0000000..8141fd9 --- /dev/null +++ b/packages/mobile-pwa/src/api/events.ts @@ -0,0 +1,383 @@ +import { BackendEvent, MessageAttachment } from "./types"; + +export type TimelineItem = + | MessageItem + | ThinkingItem + | ToolItem + | ToolResultItem + | ToolGroupItem + | HandoverItem + | RuntimeSessionItem + | ErrorItem; + +export interface BaseTimelineItem { + seq: number; + _seq: number; + author: string; + authorName?: string; + time: string; + tsISO: string; + showHeader?: boolean; +} + +export interface MessageItem extends BaseTimelineItem { + kind: "message"; + role: "user" | "assistant"; + body: string; + attachments?: MessageAttachment[]; + userSteer?: boolean; + steerAgentId?: string; + interrupted?: boolean; + optimistic?: boolean; + _thought?: ThinkingItem; +} + +export interface ThinkingItem extends BaseTimelineItem { + kind: "thinking"; + reasoning: string; + seconds: number; +} + +export interface ToolItem extends BaseTimelineItem { + kind: "tool"; + callId: string; + tool: string; + path: string; + input: Record | null; + result: "pending" | "ok" | "error"; + detail?: string; + output?: string; + compact?: boolean; +} + +export interface ToolResultItem extends BaseTimelineItem { + kind: "tool_result"; + callId: string; + toolCallSeq: number; + name: string; + output: string; + compact?: boolean; +} + +export interface ToolGroupItem extends BaseTimelineItem { + kind: "tool_group"; + events: ToolItem[]; +} + +export interface HandoverItem extends BaseTimelineItem { + kind: "handover"; + subtype: string; + agent_id: string; + target_agent_id: string; + target_agent_name: string; + note: string; +} + +export interface RuntimeSessionItem extends BaseTimelineItem { + kind: "runtime_session"; +} + +export interface ErrorItem extends BaseTimelineItem { + kind: "error"; + subtype: string; + code: string; + message: string; + agent_id: string; + agent_name: string; + target_agent_id: string; + target_agent_name: string; +} + +export interface HandoverDividerItem { + kind: "handover_divider"; + seq: number; + _seq: number; + from: string; + to: string; + fromName?: string; + toName?: string; + subtype?: string; + note?: string; + synthetic?: boolean; +} + +export type RenderableTimelineItem = TimelineItem | HandoverDividerItem; + +function eventTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return `${date.getHours()}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function summarizeToolInput(input: Record | null | undefined): string { + if (input == null) return ""; + const preferred = ["_summary", "command", "cmd", "path", "file_path", "file", "args", "query", "prompt", "pattern", "url"]; + for (const key of preferred) { + const value = input[key]; + if (typeof value === "string" && value) return value; + } + const values = Object.values(input).filter((value): value is string => typeof value === "string" && Boolean(value)); + if (values.length) return values.join(" "); + return JSON.stringify(input); +} + +export function mapBackendEvent(event: BackendEvent): TimelineItem | null { + const time = eventTime(event.ts); + const tsISO = event.ts || ""; + const seq = event.seq; + if (event.type === "message") { + const role = event.message?.role || "assistant"; + const attachments = event.message?.attachments || []; + return { + kind: "message", + seq, + _seq: seq, + author: role === "user" ? "__human__" : event.actor_agent_id, + authorName: role === "user" ? "You" : event.actor_agent_name, + role, + body: event.message?.content || "", + attachments: attachments.length ? attachments : undefined, + userSteer: Boolean(event.message?.user_steer), + steerAgentId: event.message?.steer_agent_id, + interrupted: Boolean(event.message?.interrupted), + time, + tsISO + }; + } + if (event.type === "thinking") { + return { + kind: "thinking", + seq, + _seq: seq, + author: event.actor_agent_id, + authorName: event.actor_agent_name, + reasoning: event.thinking?.content || "", + seconds: 0, + time, + tsISO + }; + } + if (event.type === "tool_call") { + const input = event.tool_call?.input || null; + return { + kind: "tool", + seq, + _seq: seq, + author: event.actor_agent_id, + authorName: event.actor_agent_name, + callId: event.tool_call?.call_id || "", + tool: event.tool_call?.name || "tool", + path: summarizeToolInput(input), + input, + result: "pending", + compact: Boolean(event.tool_call?.compact), + time, + tsISO + }; + } + if (event.type === "tool_call_result") { + return { + kind: "tool_result", + seq, + _seq: seq, + author: event.actor_agent_id, + authorName: event.actor_agent_name, + callId: event.tool_call_result?.call_id || "", + toolCallSeq: event.tool_call_result?.tool_call_seq || 0, + name: event.tool_call_result?.name || "", + output: event.tool_call_result?.output || "", + compact: Boolean(event.tool_call_result?.compact), + time, + tsISO + }; + } + if (event.type === "runtime_session") { + return { + kind: "runtime_session", + seq, + _seq: seq, + author: event.actor_agent_id, + authorName: event.actor_agent_name, + time, + tsISO + }; + } + if (event.type === "handover") { + return { + kind: "handover", + seq, + _seq: seq, + author: event.actor_agent_id, + authorName: event.actor_agent_name, + subtype: event.handover?.subtype || "delegate", + agent_id: event.actor_agent_id, + target_agent_id: event.handover?.agent_id || "", + target_agent_name: event.handover?.agent_name || "", + note: event.handover?.note || "", + time, + tsISO + }; + } + if (event.type === "error") { + return { + kind: "error", + seq, + _seq: seq, + author: event.actor_agent_id, + authorName: event.actor_agent_name, + subtype: event.error?.subtype || "error", + code: event.error?.code || "", + message: event.error?.message || "", + agent_id: event.error?.agent_id || event.actor_agent_id || "", + agent_name: event.error?.agent_name || "", + target_agent_id: event.error?.target_agent_id || "", + target_agent_name: event.error?.target_agent_name || "", + time, + tsISO + }; + } + return null; +} + +function mergeToolResults(events: TimelineItem[]): TimelineItem[] { + const out: TimelineItem[] = []; + for (const event of events) { + if (event.kind !== "tool_result") { + out.push(event); + continue; + } + let merged = false; + for (let i = out.length - 1; i >= 0; i--) { + const prev = out[i]; + if (prev.kind === "tool" && prev._seq === event.toolCallSeq && prev.result === "pending") { + out[i] = { + ...prev, + result: "ok", + detail: event.output.slice(0, 120), + output: event.output, + compact: prev.compact || event.compact + }; + merged = true; + break; + } + } + if (!merged) out.push(event); + } + return out; +} + +function prepareEvents(events: TimelineItem[]): TimelineItem[] { + const visible = mergeToolResults(events).filter(event => event.kind !== "runtime_session"); + const out: TimelineItem[] = []; + for (let i = 0; i < visible.length; i++) { + const event = visible[i]; + if (event.kind === "thinking") { + const next = visible[i + 1]; + if (next?.kind === "message" && next.author === event.author) { + out.push({ ...next, _thought: event }); + i += 1; + continue; + } + } + out.push(event); + } + return out; +} + +function groupConsecutiveTools(events: TimelineItem[]): TimelineItem[] { + const out: TimelineItem[] = []; + for (const event of events) { + if (event.kind === "tool") { + const last = out[out.length - 1]; + if (last?.kind === "tool_group" && last.author === event.author) { + last.events.push(event); + continue; + } + out.push({ + kind: "tool_group", + seq: event.seq, + _seq: event._seq, + author: event.author, + authorName: event.authorName, + time: event.time, + tsISO: event.tsISO, + events: [event] + }); + continue; + } + out.push(event); + } + return out.map(event => (event.kind === "tool_group" && event.events.length === 1 ? event.events[0] : event)); +} + +function withHeaderState(event: TimelineItem, showHeader: boolean): TimelineItem { + if (event.showHeader === showHeader) return event; + return { ...event, showHeader }; +} + +export function buildRenderableTimeline(events: TimelineItem[]): RenderableTimelineItem[] { + const prepared = groupConsecutiveTools(prepareEvents(events)); + const out: RenderableTimelineItem[] = []; + let prevAgentActor = ""; + let prevAgentName = ""; + let prevDisplayedActor = ""; + const isAgentActor = (id: string) => id && id !== "__human__"; + + prepared.forEach((event, index) => { + if (event.kind === "handover") { + const from = event.agent_id || event.author; + const to = event.target_agent_id; + if (from && to && from !== to) { + out.push({ + kind: "handover_divider", + seq: event.seq, + _seq: event._seq, + from, + to, + fromName: event.authorName, + toName: event.target_agent_name, + subtype: event.subtype, + note: event.note + }); + prevAgentActor = to; + prevAgentName = event.target_agent_name; + prevDisplayedActor = ""; + } else if (from && to && from === to) { + prevAgentActor = to; + prevAgentName = event.target_agent_name; + } + return; + } + + if (isAgentActor(event.author) && prevAgentActor && event.author !== prevAgentActor) { + out.push({ + kind: "handover_divider", + seq: event.seq, + _seq: event._seq - 0.1, + from: prevAgentActor, + to: event.author, + fromName: prevAgentName, + toName: event.authorName, + synthetic: true + }); + prevDisplayedActor = ""; + } + + const actor = event.author; + const isHeaderless = !isAgentActor(actor) || event.kind === "tool_result"; + const showHeader = isHeaderless ? true : prevDisplayedActor !== actor; + out.push(withHeaderState(event, showHeader)); + if (isAgentActor(event.author)) { + prevAgentActor = event.author; + prevAgentName = event.authorName || prevAgentName; + } + if (!isAgentActor(event.author)) { + prevAgentActor = prevAgentActor || ""; + prevDisplayedActor = ""; + } else if (!isHeaderless && showHeader) { + prevDisplayedActor = actor; + } + if (index === prepared.length - 1) return; + }); + return out; +} diff --git a/packages/mobile-pwa/src/api/types.ts b/packages/mobile-pwa/src/api/types.ts new file mode 100644 index 0000000..8d94aef --- /dev/null +++ b/packages/mobile-pwa/src/api/types.ts @@ -0,0 +1,104 @@ +export interface Project { + id: string; + name: string; + workdir: string; + main_agent_id?: string; + updated_at?: string; +} + +export interface Agent { + id: string; + name: string; + instruction: string; + runtime_id: string; + model: string; + skill_ids: string[]; +} + +export interface ChatIndexEntry { + chat_id?: string; + id?: string; + title: string; + status: string; + current_agent_id: string; + updated_at: string; +} + +export interface Chat { + id: string; + project_id: string; + title: string; + main_agent_id: string; + current_agent_id: string; + participant_agent_ids: string[]; + status: string; + stream?: { + status?: string; + pending_steers?: Array<{ + id: string; + content: string; + attachments?: MessageAttachment[]; + queued_at: string; + }>; + }; +} + +export interface BackendEvent { + seq: number; + type: "message" | "thinking" | "tool_call" | "tool_call_result" | "runtime_session" | "handover" | "error"; + ts: string; + actor_agent_id: string; + actor_agent_name?: string; + message?: { + role: "user" | "assistant"; + content: string; + attachments?: MessageAttachment[]; + user_steer?: boolean; + steer_agent_id?: string; + interrupted?: boolean; + }; + thinking?: { + content: string; + }; + tool_call?: { + call_id?: string; + name: string; + input?: Record; + compact?: boolean; + }; + tool_call_result?: { + call_id?: string; + tool_call_seq?: number; + name: string; + output?: string; + compact?: boolean; + }; + handover?: { + subtype: string; + agent_id: string; + agent_name: string; + note?: string; + }; + error?: { + subtype?: string; + code: string; + message: string; + agent_id?: string; + agent_name?: string; + target_agent_id?: string; + target_agent_name?: string; + }; +} + +export interface ToolDetails { + tool_call: BackendEvent; + tool_result?: BackendEvent | null; +} + +export interface MessageAttachment { + display_name: string; + path: string; + kind: "file" | "image" | "folder"; + thumbnail_jpeg_base64?: string; + thumbnail_failed?: boolean; +} diff --git a/packages/mobile-pwa/src/client/MobileClientProvider.tsx b/packages/mobile-pwa/src/client/MobileClientProvider.tsx new file mode 100644 index 0000000..b378a36 --- /dev/null +++ b/packages/mobile-pwa/src/client/MobileClientProvider.tsx @@ -0,0 +1,310 @@ +import React from "react"; +import { CrewApi } from "@/api/client"; +import { connectPairedDevice, PairedProfile, registerPairing } from "@/remote/client"; +import { parsePairingOffer } from "@/remote/pairingOffer"; +import { DesktopOfflineError, RelayConnectionError } from "@/remote/relay"; +import { JsonRpcPeer } from "@/remote/rpc"; +import { clearPairing, loadPairing, savePairing } from "@/storage/pairingStore"; + +type Status = "loading" | "unpaired" | "connecting" | "reconnecting" | "online" | "error"; +type ConnectionIssue = "" | "relay" | "desktop"; + +interface MobileClientContextValue { + status: Status; + profile: PairedProfile | null; + api: CrewApi | null; + error: string; + connectionIssue: ConnectionIssue; + pairWithQrText: (text: string) => Promise; + reconnect: () => Promise; + disconnect: () => Promise; +} + +const MobileClientContext = React.createContext(null); + +const relayRetryDelayMs = 1000; +const keepAliveIntervalMs = 10000; +const keepAliveTimeoutMs = 5000; + +function deviceName(): string { + const platform = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform || navigator.platform || "Browser"; + return `PWA on ${platform}`; +} + +export function MobileClientProvider({ children }: { children: React.ReactNode }) { + const [status, setStatus] = React.useState("loading"); + const [profile, setProfile] = React.useState(null); + const [api, setApi] = React.useState(null); + const [error, setError] = React.useState(""); + const [connectionIssue, setConnectionIssue] = React.useState(""); + const rpcRef = React.useRef(null); + const reconnectTimerRef = React.useRef | null>(null); + const reconnectAttemptRef = React.useRef(0); + const keepAliveTimerRef = React.useRef | null>(null); + const connectStoredPairingRef = React.useRef<(options?: { resetBackoff?: boolean; silent?: boolean }) => Promise>(async () => {}); + const mountedRef = React.useRef(true); + const statusRef = React.useRef("loading"); + const revokedRef = React.useRef(false); + + React.useEffect(() => { + statusRef.current = status; + }, [status]); + + const clearReconnectTimer = React.useCallback(() => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, []); + + const stopKeepAlive = React.useCallback(() => { + if (keepAliveTimerRef.current) { + clearInterval(keepAliveTimerRef.current); + keepAliveTimerRef.current = null; + } + }, []); + + const closeRpc = React.useCallback(() => { + stopKeepAlive(); + rpcRef.current?.close(); + rpcRef.current = null; + setApi(null); + }, [stopKeepAlive]); + + const showDesktopOffline = React.useCallback((message = "Can't connect to the Crew44 desktop") => { + if (!mountedRef.current) return; + clearReconnectTimer(); + setApi(null); + setError(message); + setConnectionIssue("desktop"); + setStatus("error"); + }, [clearReconnectTimer]); + + const showRelayError = React.useCallback((message = "Relay connection failed") => { + if (!mountedRef.current) return; + clearReconnectTimer(); + setApi(null); + setError(message); + setConnectionIssue("relay"); + setStatus("error"); + }, [clearReconnectTimer]); + + const scheduleRelayReconnect = React.useCallback((message: string) => { + if (!mountedRef.current) return; + setApi(null); + setError(message); + setConnectionIssue("relay"); + if (reconnectAttemptRef.current >= 1) { + showRelayError(message); + return; + } + setStatus("reconnecting"); + if (reconnectTimerRef.current) return; + reconnectAttemptRef.current += 1; + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null; + connectStoredPairingRef.current({ resetBackoff: false, silent: true }).catch(() => {}); + }, relayRetryDelayMs); + }, [showRelayError]); + + const classifyConnectionLoss = React.useCallback(async () => { + const saved = await loadPairing(); + if (!saved) { + setProfile(null); + setConnectionIssue(""); + setStatus("unpaired"); + return; + } + setProfile(saved.profile); + showDesktopOffline("Can't connect to the Crew44 desktop"); + }, [showDesktopOffline]); + + const classifyConnectError = React.useCallback((err: unknown) => { + if (err instanceof DesktopOfflineError) { + showDesktopOffline("Can't connect to the Crew44 desktop"); + return; + } + if (err instanceof RelayConnectionError) { + scheduleRelayReconnect(err.message); + return; + } + showDesktopOffline(err instanceof Error ? err.message : "Can't connect to the Crew44 desktop"); + }, [scheduleRelayReconnect, showDesktopOffline]); + + const handleRemoteRevoked = React.useCallback(async () => { + revokedRef.current = true; + clearReconnectTimer(); + reconnectAttemptRef.current = 0; + closeRpc(); + await clearPairing(); + setProfile(null); + setConnectionIssue(""); + setError(""); + setStatus("unpaired"); + window.alert("This browser was unpaired from the Crew44 desktop. Pair again to reconnect."); + }, [clearReconnectTimer, closeRpc]); + + const pingRpc = React.useCallback(async (rpc: JsonRpcPeer) => { + let timeoutId: ReturnType | null = null; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("RPC keepalive timed out")), keepAliveTimeoutMs); + }); + try { + await Promise.race([rpc.call("system.health"), timeout]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + }, []); + + const startKeepAlive = React.useCallback((rpc: JsonRpcPeer) => { + stopKeepAlive(); + const ping = async () => { + try { + await pingRpc(rpc); + } catch (err) { + if (rpcRef.current !== rpc || revokedRef.current) return; + const closeError = err instanceof Error ? err : new Error("RPC keepalive failed"); + rpcRef.current = null; + setApi(null); + rpc.close(closeError); + classifyConnectionLoss().catch(() => showDesktopOffline("Can't connect to the Crew44 desktop")); + } + }; + keepAliveTimerRef.current = setInterval(() => { + ping().catch(() => {}); + }, keepAliveIntervalMs); + }, [classifyConnectionLoss, pingRpc, showDesktopOffline, stopKeepAlive]); + + const connectStoredPairing = React.useCallback(async (options: { resetBackoff?: boolean; silent?: boolean } = {}) => { + clearReconnectTimer(); + if (options.resetBackoff !== false) reconnectAttemptRef.current = 0; + revokedRef.current = false; + closeRpc(); + if (!options.silent) { + setStatus("connecting"); + setError(""); + setConnectionIssue(""); + } + const saved = await loadPairing(); + if (!saved) { + setProfile(null); + setConnectionIssue(""); + setStatus("unpaired"); + return; + } + setProfile(saved.profile); + try { + const connection = { rpc: null as JsonRpcPeer | null }; + const rpc = await connectPairedDevice(saved.profile, saved.privateKey, err => { + if (!connection.rpc || rpcRef.current !== connection.rpc || revokedRef.current) return; + stopKeepAlive(); + rpcRef.current = null; + setApi(null); + classifyConnectionLoss().catch(() => showDesktopOffline("Can't connect to the Crew44 desktop")); + }, handleRemoteRevoked); + connection.rpc = rpc; + rpcRef.current = rpc; + setApi(new CrewApi(rpc)); + startKeepAlive(rpc); + reconnectAttemptRef.current = 0; + setConnectionIssue(""); + setError(""); + setStatus("online"); + } catch (err) { + classifyConnectError(err); + } + }, [classifyConnectError, classifyConnectionLoss, clearReconnectTimer, closeRpc, handleRemoteRevoked, showDesktopOffline, startKeepAlive, stopKeepAlive]); + + React.useEffect(() => { + connectStoredPairingRef.current = connectStoredPairing; + }, [connectStoredPairing]); + + React.useEffect(() => { + connectStoredPairing(); + return () => { + mountedRef.current = false; + clearReconnectTimer(); + closeRpc(); + }; + }, [clearReconnectTimer, closeRpc, connectStoredPairing]); + + React.useEffect(() => { + const onFocus = () => { + if (statusRef.current !== "online") return; + const rpc = rpcRef.current; + if (!rpc) return; + pingRpc(rpc).catch(err => { + if (rpcRef.current !== rpc || revokedRef.current) return; + rpcRef.current = null; + setApi(null); + rpc.close(err instanceof Error ? err : new Error("RPC keepalive failed")); + classifyConnectionLoss().catch(() => showDesktopOffline("Can't connect to the Crew44 desktop")); + }); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onFocus); + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onFocus); + }; + }, [classifyConnectionLoss, pingRpc, showDesktopOffline]); + + const pairWithQrText = React.useCallback(async (text: string) => { + clearReconnectTimer(); + reconnectAttemptRef.current = 0; + revokedRef.current = false; + setStatus("connecting"); + setError(""); + setConnectionIssue(""); + closeRpc(); + try { + const offer = parsePairingOffer(text); + const result = await registerPairing(offer, deviceName()); + await savePairing(result.profile, result.privateKey); + setProfile(result.profile); + await connectStoredPairing(); + } catch (err) { + setStatus("unpaired"); + setError(err instanceof Error ? err.message : "Pairing failed"); + setConnectionIssue(""); + throw err; + } + }, [clearReconnectTimer, closeRpc, connectStoredPairing]); + + const disconnect = React.useCallback(async () => { + const currentApi = api; + const desktopDeviceId = profile?.deviceId || ""; + const wasDesktopConnected = Boolean(currentApi && desktopDeviceId); + clearReconnectTimer(); + reconnectAttemptRef.current = 0; + revokedRef.current = true; + if (currentApi && desktopDeviceId) { + await currentApi.deleteRemoteDevice(desktopDeviceId).catch(() => {}); + } + closeRpc(); + await clearPairing(); + setProfile(null); + setConnectionIssue(""); + setError(wasDesktopConnected ? "" : "Also unpair this browser on desktop before pairing again."); + setStatus("unpaired"); + }, [api, clearReconnectTimer, closeRpc, profile]); + + const value = React.useMemo(() => ({ + status, + profile, + api, + error, + connectionIssue, + pairWithQrText, + reconnect: connectStoredPairing, + disconnect + }), [status, profile, api, error, connectionIssue, pairWithQrText, connectStoredPairing, disconnect]); + + return {children}; +} + +export function useMobileClient(): MobileClientContextValue { + const value = React.useContext(MobileClientContext); + if (!value) throw new Error("useMobileClient must be used inside MobileClientProvider"); + return value; +} diff --git a/packages/mobile-pwa/src/main.tsx b/packages/mobile-pwa/src/main.tsx new file mode 100644 index 0000000..1540207 --- /dev/null +++ b/packages/mobile-pwa/src/main.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import { MobileClientProvider } from "@/client/MobileClientProvider"; +import "./styles.css"; + +if ("serviceWorker" in navigator && import.meta.env.PROD) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch(() => {}); + }); +} + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/packages/mobile-pwa/src/pages/AgentPage.tsx b/packages/mobile-pwa/src/pages/AgentPage.tsx new file mode 100644 index 0000000..63ad936 --- /dev/null +++ b/packages/mobile-pwa/src/pages/AgentPage.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { Agent } from "@/api/types"; +import { useMobileClient } from "@/client/MobileClientProvider"; +import { EmptyState, Header, IconButton, LoadingState, OfflineState, Screen } from "@/ui/Screen"; +import { BackIcon } from "@/ui/icons"; + +export function AgentPage({ + agentId, + navigate +}: { + agentId: string; + navigate: (path: string) => void; +}) { + const client = useMobileClient(); + const [agent, setAgent] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(""); + + React.useEffect(() => { + let cancelled = false; + async function load() { + if (!client.api) return; + setLoading(true); + setError(""); + try { + const agents = await client.api.listAgents(); + if (!cancelled) setAgent(agents.find(item => item.id === agentId) || null); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load agent"); + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + return () => { + cancelled = true; + }; + }, [agentId, client.api]); + + if (client.status === "error" && !client.api) { + return ( + +
navigate("/agents")}>} /> + + + ); + } + + return ( + +
navigate("/agents")}>} /> + {loading ? : error ? ( + + ) : !agent ? ( + + ) : ( +
+
+ Runtime + {agent.runtime_id || "Not set"} +
+
+ Model + {agent.model || "Not set"} +
+

Instructions

+

{agent.instruction || "No instructions."}

+
+ )} + + ); +} diff --git a/packages/mobile-pwa/src/pages/AgentsPage.tsx b/packages/mobile-pwa/src/pages/AgentsPage.tsx new file mode 100644 index 0000000..ae8e0fd --- /dev/null +++ b/packages/mobile-pwa/src/pages/AgentsPage.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { Agent } from "@/api/types"; +import { useMobileClient } from "@/client/MobileClientProvider"; +import { EmptyState, Header, IconButton, LoadingState, OfflineState, Row, Screen } from "@/ui/Screen"; +import { BackIcon } from "@/ui/icons"; + +export function AgentsPage({ navigate }: { navigate: (path: string) => void }) { + const client = useMobileClient(); + const [agents, setAgents] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(""); + + const load = React.useCallback(async () => { + if (!client.api) return; + setLoading(true); + setError(""); + try { + setAgents(await client.api.listAgents()); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load agents"); + } finally { + setLoading(false); + } + }, [client.api]); + + React.useEffect(() => { + load(); + }, [load]); + + if (client.status === "error" && !client.api) { + return ( + +
navigate("/")}>} /> + + + ); + } + + return ( + +
navigate("/")}>} /> + {loading ? : error ? ( + + ) : agents.length === 0 ? ( + + ) : ( +
+ {agents.map(agent => ( + navigate(`/agents/${agent.id}`)} + /> + ))} +
+ )} + + ); +} diff --git a/packages/mobile-pwa/src/pages/ChatPage.tsx b/packages/mobile-pwa/src/pages/ChatPage.tsx new file mode 100644 index 0000000..ec06e4f --- /dev/null +++ b/packages/mobile-pwa/src/pages/ChatPage.tsx @@ -0,0 +1,250 @@ +import React from "react"; +import { buildRenderableTimeline, mapBackendEvent, TimelineItem } from "@/api/events"; +import { Agent, BackendEvent, Chat } from "@/api/types"; +import { useMobileClient } from "@/client/MobileClientProvider"; +import { Button, EmptyState, Header, IconButton, LoadingState, OfflineState, Screen } from "@/ui/Screen"; +import { BackIcon, SendIcon, StopIcon } from "@/ui/icons"; +import { Timeline } from "@/ui/Timeline"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function targetAgentFromText(value: string, agents: Agent[]): string { + const sorted = agents.filter(agent => agent.name).sort((a, b) => b.name.length - a.name.length); + for (const agent of sorted) { + const mentionRe = new RegExp(`(^|\\s)@${escapeRegExp(agent.name)}(?=$|\\s|[.,!?;:])`); + if (mentionRe.test(value)) return agent.id; + } + return ""; +} + +export function ChatPage({ + chatId, + navigate +}: { + chatId: string; + navigate: (path: string) => void; +}) { + const client = useMobileClient(); + const [chat, setChat] = React.useState(null); + const [agents, setAgents] = React.useState([]); + const [items, setItems] = React.useState([]); + const [draft, setDraft] = React.useState(""); + const [targetAgentId, setTargetAgentId] = React.useState(""); + const [loading, setLoading] = React.useState(true); + const [streaming, setStreaming] = React.useState(false); + const [error, setError] = React.useState(""); + const timelineRef = React.useRef(null); + const shouldStickToBottomRef = React.useRef(true); + const didInitialScrollRef = React.useRef(false); + const lastSeq = React.useRef(0); + const cleanupRef = React.useRef<() => void>(() => {}); + + const renderItems = React.useMemo(() => buildRenderableTimeline(items), [items]); + + const scrollToBottom = React.useCallback((smooth = true) => { + requestAnimationFrame(() => { + timelineRef.current?.scrollTo({ + top: timelineRef.current.scrollHeight, + behavior: smooth ? "smooth" : "auto" + }); + }); + }, []); + + const appendEvent = React.useCallback((event: BackendEvent) => { + lastSeq.current = Math.max(lastSeq.current, event.seq); + const mapped = mapBackendEvent(event); + if (!mapped) return; + setItems(prev => { + if (prev.some(item => item.seq === mapped.seq)) return prev; + if (mapped.kind === "message" && mapped.role === "user") { + const optimisticIndex = prev.findIndex(item => + item.kind === "message" && + item.optimistic && + item.role === "user" && + item.body === mapped.body + ); + if (optimisticIndex !== -1) { + const next = prev.slice(); + next[optimisticIndex] = mapped; + return next; + } + } + return [...prev, mapped]; + }); + }, []); + + const subscribe = React.useCallback((after: number) => { + if (!client.api || !chatId) return; + cleanupRef.current(); + setStreaming(true); + cleanupRef.current = client.api.subscribeChatEvents( + chatId, + after, + { compactTools: true }, + appendEvent, + () => { + setStreaming(false); + client.api?.getChat(chatId).then(setChat).catch(() => {}); + }, + err => { + setStreaming(false); + setError(err.message); + } + ); + }, [appendEvent, chatId, client.api]); + + const load = React.useCallback(async () => { + if (!client.api || !chatId) return; + setLoading(true); + setError(""); + cleanupRef.current(); + try { + const [nextChat, events, nextAgents] = await Promise.all([ + client.api.getChat(chatId), + client.api.listEvents(chatId, 0, { compactTools: true }), + client.api.listAgents() + ]); + setChat(nextChat); + setAgents(nextAgents); + setItems(events.map(mapBackendEvent).filter((item): item is TimelineItem => Boolean(item))); + didInitialScrollRef.current = false; + shouldStickToBottomRef.current = true; + lastSeq.current = events.reduce((seq, event) => Math.max(seq, event.seq), 0); + subscribe(lastSeq.current); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load chat"); + } finally { + setLoading(false); + } + }, [chatId, client.api, subscribe]); + + React.useEffect(() => { + load(); + return () => cleanupRef.current(); + }, [load]); + + React.useEffect(() => { + if (!chat || agents.length === 0) return; + const preferred = chat.current_agent_id || chat.main_agent_id || agents[0].id; + setTargetAgentId(current => { + if (current && agents.some(agent => agent.id === current)) return current; + return preferred; + }); + }, [agents, chat]); + + React.useEffect(() => { + if (!items.length) return; + if (!didInitialScrollRef.current || shouldStickToBottomRef.current) { + scrollToBottom(!didInitialScrollRef.current ? false : true); + didInitialScrollRef.current = true; + } + }, [items.length, scrollToBottom]); + + const handleTimelineScroll = React.useCallback(() => { + const el = timelineRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight); + shouldStickToBottomRef.current = distanceFromBottom < 64; + }, []); + + const send = React.useCallback(async () => { + if (!client.api || !chat || !draft.trim()) return; + const text = draft.trim(); + const steeringActiveRun = streaming; + setDraft(""); + shouldStickToBottomRef.current = true; + if (!steeringActiveRun) { + const optimisticSeq = -Date.now(); + setItems(prev => [...prev, { + kind: "message", + seq: optimisticSeq, + _seq: optimisticSeq, + author: "__human__", + role: "user", + body: text, + time: "now", + tsISO: new Date().toISOString(), + optimistic: true + }]); + } + try { + if (steeringActiveRun) { + await client.api.interruptMessage(chatId, text); + } else { + const mentionedTarget = targetAgentFromText(text, agents); + await client.api.postMessage(chatId, text, mentionedTarget || targetAgentId || chat.current_agent_id || chat.main_agent_id); + subscribe(lastSeq.current); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to send message"); + if (!steeringActiveRun) setStreaming(false); + } + }, [agents, chat, chatId, client.api, draft, streaming, subscribe, targetAgentId]); + + const cancel = React.useCallback(async () => { + if (!client.api) return; + await client.api.cancelChat(chatId); + cleanupRef.current(); + setStreaming(false); + }, [chatId, client.api]); + + if (client.status === "error" && !client.api) { + return ( + +
navigate("/")}>} /> + + + ); + } + + return ( + +
navigate("/")}>} /> + {loading ? : error && items.length === 0 ? ( + + + + ) : ( + <> +
+ {renderItems.length === 0 ? ( + + ) : ( + + )} +
+ {error ?

{error}

: null} + {streaming ?

Agent is working...

: null} +
{ event.preventDefault(); send().catch(() => {}); }}> + +