diff --git a/CHANGELOG.md b/CHANGELOG.md index b7361c8..1d8f1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.1] - 2026-06-01 + +### Changed +- Mobile PWA chat polish: tool rows now open from the full row, expanded tool calls show arguments in the header, multiline tool output keeps `pre` formatting, chat back returns to the project chat list, and the home menu no longer dims the page or shows blue text for `Agents`. + +### Fixed +- Desktop chat switching no longer races composer draft hydration, so fresh input is not overwritten while changing chats and the stale-draft test stops flaking. + ## [0.6.0] - 2026-05-27 ### Added diff --git a/daemon/internal/relay/server.go b/daemon/internal/relay/server.go index fcf8511..28f42fb 100644 --- a/daemon/internal/relay/server.go +++ b/daemon/internal/relay/server.go @@ -17,6 +17,8 @@ const ( relayPingPeriod = 10 * time.Second ) +var relayPendingDataTimeout = 10 * time.Second + type Server struct { mu sync.Mutex servers map[string]*serverHub @@ -32,6 +34,10 @@ type serverHub struct { type pendingConn struct { client *relayConn daemon *relayConn + timer *time.Timer + + mu sync.Mutex + settled bool } type relayConn struct { @@ -126,11 +132,19 @@ func (s *Server) serveControl(serverID string, conn *relayConn) { for { if _, _, err := conn.ws.ReadMessage(); err != nil { + var pending []*pendingConn s.mu.Lock() if s.servers[serverID] == hub && hub.control == conn { hub.control = nil + for connectionID, wait := range hub.pending { + delete(hub.pending, connectionID) + pending = append(pending, wait) + } } s.mu.Unlock() + for _, wait := range pending { + wait.finish("desktop_offline") + } log.Printf("relay control closed server_id=%s remote=%s err=%v", serverID, conn.remoteAddr, err) return } @@ -154,19 +168,16 @@ func (s *Server) serveClient(serverID string, conn *relayConn) { } hub.pending[connectionID] = pending s.mu.Unlock() + timer := time.AfterFunc(relayPendingDataTimeout, func() { + s.expirePending(serverID, connectionID, pending) + }) + pending.attachTimer(timer) log.Printf("relay client queued server_id=%s connection_id=%s remote=%s", serverID, connectionID, conn.remoteAddr) if hub.writeControl(map[string]any{"type": "client_connected", "connection_id": connectionID}) != nil { s.removePending(serverID, connectionID) log.Printf("relay client notify failed server_id=%s connection_id=%s remote=%s", serverID, connectionID, conn.remoteAddr) - _ = conn.writeJSON(map[string]any{"type": "desktop_offline"}) - conn.close() - return - } - if err := conn.writeJSON(map[string]any{"type": "desktop_online"}); err != nil { - s.removePending(serverID, connectionID) - log.Printf("relay client status write failed server_id=%s connection_id=%s remote=%s err=%v", serverID, connectionID, conn.remoteAddr, err) - conn.close() + pending.finish("desktop_offline") return } } @@ -183,7 +194,6 @@ func (s *Server) serveDaemonData(serverID, connectionID string, conn *relayConn) pending := hub.pending[connectionID] if pending != nil { delete(hub.pending, connectionID) - pending.daemon = conn } s.mu.Unlock() if pending == nil || pending.client == nil { @@ -191,11 +201,34 @@ func (s *Server) serveDaemonData(serverID, connectionID string, conn *relayConn) conn.close() return } + if !pending.beginBridge(conn) { + log.Printf("relay daemon-data expired server_id=%s connection_id=%s remote=%s", serverID, connectionID, conn.remoteAddr) + conn.close() + return + } + if err := pending.client.writeJSON(map[string]any{"type": "desktop_online"}); err != nil { + log.Printf("relay client status write failed server_id=%s connection_id=%s remote=%s err=%v", serverID, connectionID, pending.client.remoteAddr, err) + pending.client.close() + conn.close() + return + } log.Printf("relay bridge open server_id=%s connection_id=%s client_remote=%s daemon_remote=%s", serverID, connectionID, pending.client.remoteAddr, conn.remoteAddr) bridge(pending.client, pending.daemon) log.Printf("relay bridge closed server_id=%s connection_id=%s", serverID, connectionID) } +func (s *Server) expirePending(serverID, connectionID string, pending *pendingConn) { + s.mu.Lock() + hub := s.servers[serverID] + if hub == nil || hub.pending[connectionID] != pending { + s.mu.Unlock() + return + } + delete(hub.pending, connectionID) + s.mu.Unlock() + pending.finish("desktop_timeout") +} + func (s *Server) removePending(serverID, connectionID string) { s.mu.Lock() defer s.mu.Unlock() @@ -223,6 +256,54 @@ func (h *serverHub) writeControl(value any) error { return h.control.writeJSON(value) } +func (p *pendingConn) beginBridge(daemon *relayConn) bool { + p.mu.Lock() + if p.settled { + p.mu.Unlock() + return false + } + p.settled = true + timer := p.timer + p.timer = nil + p.daemon = daemon + p.mu.Unlock() + if timer != nil { + timer.Stop() + } + return true +} + +func (p *pendingConn) attachTimer(timer *time.Timer) { + p.mu.Lock() + if p.settled { + p.mu.Unlock() + timer.Stop() + return + } + p.timer = timer + p.mu.Unlock() +} + +func (p *pendingConn) finish(status string) { + p.mu.Lock() + if p.settled { + p.mu.Unlock() + return + } + p.settled = true + timer := p.timer + p.timer = nil + client := p.client + p.mu.Unlock() + if timer != nil { + timer.Stop() + } + if client != nil { + _ = client.writeJSON(map[string]any{"type": status}) + client.close() + } +} + func bridge(a, b *relayConn) { done := make(chan struct{}, 2) go proxy(a, b, done) diff --git a/daemon/internal/relay/server_test.go b/daemon/internal/relay/server_test.go new file mode 100644 index 0000000..cef550c --- /dev/null +++ b/daemon/internal/relay/server_test.go @@ -0,0 +1,230 @@ +package relay + +import ( + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestRelayClientWithoutControlGetsDesktopOffline(t *testing.T) { + wsURL, cleanup := startRelayTestServer(t) + defer cleanup() + + client := dialRelayRole(t, wsURL, map[string]string{ + "role": "client", + "server_id": "server-1", + }) + defer client.Close() + + if status := readRelayStatus(t, client); status != "desktop_offline" { + t.Fatalf("status = %q, want desktop_offline", status) + } +} + +func TestRelayClientTimesOutWhenDataDoesNotArrive(t *testing.T) { + wsURL, cleanup := startRelayTestServer(t) + defer cleanup() + + restoreTimeout := setRelayPendingDataTimeout(t, 50*time.Millisecond) + defer restoreTimeout() + + control := dialRelayRole(t, wsURL, map[string]string{ + "role": "daemon-control", + "server_id": "server-2", + }) + defer control.Close() + + client := dialRelayRole(t, wsURL, map[string]string{ + "role": "client", + "server_id": "server-2", + }) + defer client.Close() + + msg := readControlMessage(t, control) + if msg.Type != "client_connected" || msg.ConnectionID == "" { + t.Fatalf("control message = %#v, want client_connected with connection id", msg) + } + if status := readRelayStatus(t, client); status != "desktop_timeout" { + t.Fatalf("status = %q, want desktop_timeout", status) + } +} + +func TestRelayClientGetsDesktopOfflineWhenControlDropsWhilePending(t *testing.T) { + wsURL, cleanup := startRelayTestServer(t) + defer cleanup() + + restoreTimeout := setRelayPendingDataTimeout(t, time.Second) + defer restoreTimeout() + + control := dialRelayRole(t, wsURL, map[string]string{ + "role": "daemon-control", + "server_id": "server-3", + }) + client := dialRelayRole(t, wsURL, map[string]string{ + "role": "client", + "server_id": "server-3", + }) + defer client.Close() + + msg := readControlMessage(t, control) + if msg.Type != "client_connected" || msg.ConnectionID == "" { + t.Fatalf("control message = %#v, want client_connected with connection id", msg) + } + control.Close() + + if status := readRelayStatus(t, client); status != "desktop_offline" { + t.Fatalf("status = %q, want desktop_offline", status) + } +} + +func TestRelayClientGetsDesktopOnlineAfterDataConnects(t *testing.T) { + wsURL, cleanup := startRelayTestServer(t) + defer cleanup() + + restoreTimeout := setRelayPendingDataTimeout(t, time.Second) + defer restoreTimeout() + + control := dialRelayRole(t, wsURL, map[string]string{ + "role": "daemon-control", + "server_id": "server-4", + }) + defer control.Close() + + client := dialRelayRole(t, wsURL, map[string]string{ + "role": "client", + "server_id": "server-4", + }) + defer client.Close() + + msg := readControlMessage(t, control) + if msg.Type != "client_connected" || msg.ConnectionID == "" { + t.Fatalf("control message = %#v, want client_connected with connection id", msg) + } + + daemon := dialRelayRole(t, wsURL, map[string]string{ + "role": "daemon-data", + "server_id": "server-4", + "connection_id": msg.ConnectionID, + }) + defer daemon.Close() + + if status := readRelayStatus(t, client); status != "desktop_online" { + t.Fatalf("status = %q, want desktop_online", status) + } + + if err := daemon.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil { + t.Fatalf("daemon write: %v", err) + } + messageType, payload, err := client.ReadMessage() + if err != nil { + t.Fatalf("client read bridged payload: %v", err) + } + if messageType != websocket.TextMessage || string(payload) != "hello" { + t.Fatalf("bridged payload = type:%d payload:%q, want text hello", messageType, payload) + } +} + +func TestLateDaemonDataIsRejectedAfterTimeout(t *testing.T) { + wsURL, cleanup := startRelayTestServer(t) + defer cleanup() + + restoreTimeout := setRelayPendingDataTimeout(t, 50*time.Millisecond) + defer restoreTimeout() + + control := dialRelayRole(t, wsURL, map[string]string{ + "role": "daemon-control", + "server_id": "server-5", + }) + defer control.Close() + + client := dialRelayRole(t, wsURL, map[string]string{ + "role": "client", + "server_id": "server-5", + }) + defer client.Close() + + msg := readControlMessage(t, control) + if msg.Type != "client_connected" || msg.ConnectionID == "" { + t.Fatalf("control message = %#v, want client_connected with connection id", msg) + } + if status := readRelayStatus(t, client); status != "desktop_timeout" { + t.Fatalf("status = %q, want desktop_timeout", status) + } + + daemon := dialRelayRole(t, wsURL, map[string]string{ + "role": "daemon-data", + "server_id": "server-5", + "connection_id": msg.ConnectionID, + }) + defer daemon.Close() + _ = daemon.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if _, _, err := daemon.ReadMessage(); err == nil { + t.Fatalf("late daemon-data read = nil error, want closed connection") + } +} + +func startRelayTestServer(t *testing.T) (string, func()) { + t.Helper() + server := httptest.NewServer(NewServer()) + return "ws" + strings.TrimPrefix(server.URL, "http") + "/relay", server.Close +} + +func dialRelayRole(t *testing.T, wsURL string, params map[string]string) *websocket.Conn { + t.Helper() + u, err := url.Parse(wsURL) + if err != nil { + t.Fatalf("parse ws url: %v", err) + } + query := u.Query() + for key, value := range params { + query.Set(key, value) + } + u.RawQuery = query.Encode() + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + t.Fatalf("dial relay role %q: %v", params["role"], err) + } + return conn +} + +func readRelayStatus(t *testing.T, conn *websocket.Conn) string { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + defer conn.SetReadDeadline(time.Time{}) + var msg struct { + Type string `json:"type"` + } + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("read relay status: %v", err) + } + return msg.Type +} + +type controlMessage struct { + Type string `json:"type"` + ConnectionID string `json:"connection_id"` +} + +func readControlMessage(t *testing.T, conn *websocket.Conn) controlMessage { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + defer conn.SetReadDeadline(time.Time{}) + var msg controlMessage + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("read control message: %v", err) + } + return msg +} + +func setRelayPendingDataTimeout(t *testing.T, timeout time.Duration) func() { + t.Helper() + original := relayPendingDataTimeout + relayPendingDataTimeout = timeout + return func() { + relayPendingDataTimeout = original + } +} diff --git a/daemon/internal/remote/manager.go b/daemon/internal/remote/manager.go index f5d8803..68729ee 100644 --- a/daemon/internal/remote/manager.go +++ b/daemon/internal/remote/manager.go @@ -17,10 +17,22 @@ import ( ) const ( - pairingTTL = 5 * time.Minute - pairingType = "crew44-remote-pairing" + pairingTTL = 5 * time.Minute + pairingType = "crew44-remote-pairing" + mobilePairURL = "https://mobileapp.crew44.io/" ) +type qrPairingSecret struct { + Version int `json:"v"` + RelayURL string `json:"r"` + ServerID string `json:"s"` + DesktopName string `json:"n,omitempty"` + DaemonPubKey string `json:"k"` + PairingID string `json:"p"` + PairingSecret string `json:"x"` + ExpiresAt time.Time `json:"e"` +} + type Manager struct { store *Store identity Identity @@ -112,10 +124,20 @@ func (m *Manager) CreatePairing(_ context.Context, relayURL string) (any, error) PairingSecret: newSecret(), ExpiresAt: now.Add(pairingTTL), } - qr, err := json.Marshal(offer) + qrSecret, err := json.Marshal(qrPairingSecret{ + Version: offer.Version, + RelayURL: offer.RelayURL, + ServerID: offer.ServerID, + DesktopName: offer.DesktopName, + DaemonPubKey: offer.DaemonPubKey, + PairingID: offer.PairingID, + PairingSecret: offer.PairingSecret, + ExpiresAt: offer.ExpiresAt, + }) if err != nil { return nil, err } + qrText := mobilePairURL + "#secret=" + url.QueryEscape(string(qrSecret)) m.mu.Lock() m.pruneExpiredPairingsLocked(now) @@ -125,7 +147,7 @@ func (m *Manager) CreatePairing(_ context.Context, relayURL string) (any, error) m.relay.Ensure(relayURL) return map[string]any{ "offer": offer, - "qr_text": string(qr), + "qr_text": qrText, }, nil } diff --git a/daemon/internal/remote/remote_integration_test.go b/daemon/internal/remote/remote_integration_test.go index 956b7c2..cabd797 100644 --- a/daemon/internal/remote/remote_integration_test.go +++ b/daemon/internal/remote/remote_integration_test.go @@ -175,12 +175,11 @@ func dialDeviceOverRelay(t *testing.T, relayURL string, offer remote.PairingOffe } // eventuallyDialRelayClient dials the relay's client role and waits until -// the daemon's control connection has registered so the server greets us -// with "desktop_online" instead of "desktop_offline". The relay accepts -// the client websocket even before control is in place — it just immediately -// sends desktop_offline and closes — so polling on dial success alone races -// with the daemon's relay-client goroutine in CI. We poll the first frame -// and retry until we either see desktop_online or hit the deadline. +// the daemon is fully ready for the session, which the relay now signals +// by greeting the client with "desktop_online". Before control is connected +// (or before daemon-data is ready), the relay responds with a non-online +// status and closes the connection, so polling the first frame avoids +// racing the daemon's relay-client goroutine in CI. // // On success the desktop_online greeting has been consumed, so callers // proceed directly to the noise handshake. The deadline is generous @@ -196,7 +195,7 @@ func eventuallyDialRelayClient(t *testing.T, relayURL, serverID string) *websock time.Sleep(25 * time.Millisecond) continue } - _ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) var greeting struct { Type string `json:"type"` } diff --git a/electron/scripts/dev.cjs b/electron/scripts/dev.cjs index 86f5c01..75b5e61 100644 --- a/electron/scripts/dev.cjs +++ b/electron/scripts/dev.cjs @@ -8,18 +8,19 @@ const cwd = path.resolve(__dirname, '..', '..'); const viteBin = path.join(cwd, 'node_modules', '.bin', process.platform === 'win32' ? 'vite.cmd' : 'vite'); function resolveElectronBin() { - const fs = require('fs'); - const distDir = path.join(cwd, 'node_modules', 'electron', 'dist'); - if (!fs.existsSync(distDir)) { + try { + return require('electron'); + } catch (err) { console.error( 'Electron binary not found at node_modules/electron/dist/.\n' + - 'pnpm 10 blocks postinstall scripts by default. Fix with one of:\n' + - ' pnpm exec install-electron --no\n' + - ' pnpm rebuild electron' + 'The Electron npm package is installed, but its binary download did not finish.\n' + + 'Fix with:\n' + + ' npm exec install-electron --no\n' + + 'If downloads are slow or blocked, copy .npmrc.example to .npmrc and run the install command again.\n' + + `Original error: ${err.message}` ); process.exit(1); } - return require('electron'); } const electronBin = resolveElectronBin(); diff --git a/package-lock.json b/package-lock.json index aaa0a49..946897d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "crew44", - "version": "0.5.8", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crew44", - "version": "0.5.8", + "version": "0.6.1", "workspaces": [ "packages/*" ], @@ -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,27 @@ "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", + "katex": "^0.17.0", + "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..0c8838d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "crew44", "productName": "Crew44", - "version": "0.6.0", + "version": "0.6.1", "description": "Crew44 — local-first AI crew runner", "author": { "name": "Crew44", @@ -30,11 +30,16 @@ "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", "desktop:smoke:win": "npm run build:daemon:win && vite build && electron-builder --dir --win --x64", - "test": "cd daemon && go test ./... && cd .. && vitest run && npm run test:mobile", + "test": "cd daemon && go test ./... && cd .. && vitest run && npm run test:mobile-pwa && npm run test:mobile", "clean": "rm -rf bin dist release .electron-app crew44-server crew44-daemon" }, "build": { @@ -98,12 +103,6 @@ "react": "19.1.0", "react-dom": "19.1.0" }, - "pnpm": { - "onlyBuiltDependencies": [ - "electron", - "electron-winstaller" - ] - }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/packages/mobile-pwa/.gitignore b/packages/mobile-pwa/.gitignore new file mode 100644 index 0000000..d8d6511 --- /dev/null +++ b/packages/mobile-pwa/.gitignore @@ -0,0 +1,2 @@ +dist/ +dist.zip diff --git a/packages/mobile-pwa/README.md b/packages/mobile-pwa/README.md new file mode 100644 index 0000000..45ab6d5 --- /dev/null +++ b/packages/mobile-pwa/README.md @@ -0,0 +1,56 @@ +# 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 = /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..1700ae3 --- /dev/null +++ b/packages/mobile-pwa/package.json @@ -0,0 +1,30 @@ +{ + "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", + "katex": "^0.17.0", + "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 0000000..96fbb21 Binary files /dev/null and b/packages/mobile-pwa/public/icons/icon-192.png differ diff --git a/packages/mobile-pwa/public/icons/icon-512.png b/packages/mobile-pwa/public/icons/icon-512.png new file mode 100644 index 0000000..965f7b6 Binary files /dev/null and b/packages/mobile-pwa/public/icons/icon-512.png differ 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/src/App.tsx b/packages/mobile-pwa/src/App.tsx new file mode 100644 index 0000000..841e923 --- /dev/null +++ b/packages/mobile-pwa/src/App.tsx @@ -0,0 +1,92 @@ +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 { PwaInstallPromptController } from "@/pwa-install/PwaInstallPromptController"; +import { ConnectingState, Header, Screen } from "@/ui/Screen"; + +function currentPath(): string { + const hashPath = window.location.hash.replace(/^#/, ""); + if (new URLSearchParams(hashPath).has("secret")) return "/pair"; + if (hashPath.startsWith("/")) return hashPath; + return window.location.pathname === "/" ? "/" : window.location.pathname; +} + +function hasPairSecretHash(): boolean { + return new URLSearchParams(window.location.hash.replace(/^#/, "")).has("secret"); +} + +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) => { + if (window.location.pathname !== "/") window.history.replaceState(null, "", "/"); + window.location.hash = nextPath; + setPath(nextPath); + }, []); + return { path, navigate }; +} + +export default function App() { + const client = useMobileClient(); + const { path, navigate } = useHashRoute(); + + React.useEffect(() => { + if (hasPairSecretHash()) return; + if (client.status === "unpaired" && path !== "/pair") navigate("/pair"); + if (client.status === "online" && path === "/pair") navigate("/"); + }, [client.status, navigate, path]); + + let content: React.ReactNode; + + if (client.status === "loading" || client.status === "connecting") { + const label = client.status === "connecting" + ? "Connecting to the Crew44 desktop..." + : "Loading pairing..."; + content = ( + +
+ + + ); + } else if (client.status === "unpaired") { + content = ; + } else if (path === "/pair") { + content = ; + } else if (path === "/agents") { + content = ; + } else { + const projectMatch = path.match(/^\/projects\/([^/]+)$/); + const chatMatch = path.match(/^\/chats\/([^/]+)$/); + const agentMatch = path.match(/^\/agents\/([^/]+)$/); + + if (projectMatch) { + content = ; + } else if (chatMatch) { + content = ; + } else if (agentMatch) { + content = ; + } else { + content = ; + } + } + + return ( + <> + {content} + + + ); +} 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__/classifyConnectError.test.ts b/packages/mobile-pwa/src/__tests__/classifyConnectError.test.ts new file mode 100644 index 0000000..a3c7af2 --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/classifyConnectError.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { classifyConnectError } from "@/client/classifyConnectError"; +import { connectionIssueTitle } from "@/client/connectionIssue"; +import { DesktopOfflineError, DesktopTimeoutError, RelayConnectionError } from "@/remote/relay"; + +describe("classifyConnectError", () => { + it("classifies desktop offline errors as desktop issues", () => { + expect(classifyConnectError(new DesktopOfflineError())).toEqual({ + issue: "desktop", + message: "Can't connect to the Crew44 desktop" + }); + }); + + it("classifies desktop timeout errors as desktop timeout issues", () => { + expect(classifyConnectError(new DesktopTimeoutError())).toEqual({ + issue: "desktop_timeout", + message: "The Crew44 desktop did not respond within 10 seconds." + }); + }); + + it("keeps relay socket failures as relay issues", () => { + expect(classifyConnectError(new RelayConnectionError("Relay socket failed"))).toEqual({ + issue: "relay", + message: "Relay socket failed" + }); + }); + + it("maps connection issues to the expected titles", () => { + expect(connectionIssueTitle("relay")).toBe("Relay connection issue"); + expect(connectionIssueTitle("desktop")).toBe("Can't connect to the Crew44 desktop"); + expect(connectionIssueTitle("desktop_timeout")).toBe("Crew44 desktop timed out"); + }); +}); 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/__tests__/pairingOffer.test.ts b/packages/mobile-pwa/src/__tests__/pairingOffer.test.ts new file mode 100644 index 0000000..95636ba --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/pairingOffer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parsePairingOffer } from "@/remote/pairingOffer"; + +const future = "2026-05-13T12:00:00.000Z"; +const now = new Date("2026-05-13T11:00:00.000Z"); + +function secret() { + return JSON.stringify({ + v: 1, + r: "wss://relay.example.com/relay", + s: "srv_test", + n: "Studio Mac", + k: "abc123", + p: "pair_test", + x: "secret", + e: future + }); +} + +describe("parsePairingOffer", () => { + it("accepts the compact pair URL format", () => { + const offer = parsePairingOffer(`https://mobileapp.crew44.io/#secret=${encodeURIComponent(secret())}`, now); + + expect(offer).toMatchObject({ + relay_url: "wss://relay.example.com/relay", + server_id: "srv_test", + pairing_id: "pair_test" + }); + }); + + it("accepts unencoded pair URLs and scanner text with a URL prefix", () => { + expect(parsePairingOffer(`https://mobileapp.crew44.io/#secret=${secret()}`, now)).toMatchObject({ + pairing_id: "pair_test" + }); + expect(parsePairingOffer(`https://mobileapp.crew44.io/${secret()}`, now)).toMatchObject({ + pairing_id: "pair_test" + }); + }); +}); diff --git a/packages/mobile-pwa/src/__tests__/pwa-install-dismissal.test.ts b/packages/mobile-pwa/src/__tests__/pwa-install-dismissal.test.ts new file mode 100644 index 0000000..8c6d2c9 --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/pwa-install-dismissal.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hasHandledPwaInstallPrompt, markPwaInstallPromptHandled } from "@/pwa-install/dismissal"; + +describe("PWA install prompt dismissal", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("persists that the install prompt has been handled", () => { + const store = new Map(); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + } + } + }); + + expect(hasHandledPwaInstallPrompt()).toBe(false); + + markPwaInstallPromptHandled(); + + expect(hasHandledPwaInstallPrompt()).toBe(true); + }); + + it("does not throw when localStorage is unavailable", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + } + } + }); + + expect(hasHandledPwaInstallPrompt()).toBe(false); + expect(() => markPwaInstallPromptHandled()).not.toThrow(); + }); +}); diff --git a/packages/mobile-pwa/src/__tests__/rich-text.test.tsx b/packages/mobile-pwa/src/__tests__/rich-text.test.tsx new file mode 100644 index 0000000..384da8a --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/rich-text.test.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { RichText } from "@/ui/RichText"; + +describe("RichText", () => { + it("renders markdown pipe tables with inline formatting and alignment", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("10"); + expect(html).toContain("8"); + expect(html).toContain("text-align:right"); + }); + + it("renders inline and display math with KaTeX", () => { + const inline = renderToStaticMarkup(); + const block = renderToStaticMarkup(); + + expect(inline).toContain("cw-math-inline"); + expect(inline).toContain("katex"); + expect(inline).not.toContain("$x^2 + 1$"); + expect(block).toContain("cw-math-block"); + expect(block).toContain("katex-display"); + }); + + it("preserves LaTeX backslashes inside table cells", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain(" { + const html = renderToStaticMarkup(); + + expect(html).toContain("rich-code"); + expect(html).toContain("const value = 1;"); + }); +}); diff --git a/packages/mobile-pwa/src/__tests__/tool-output.test.tsx b/packages/mobile-pwa/src/__tests__/tool-output.test.tsx new file mode 100644 index 0000000..35002cc --- /dev/null +++ b/packages/mobile-pwa/src/__tests__/tool-output.test.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ToolOutput, toolOutputSections } from "@/ui/ToolOutput"; + +describe("ToolOutput", () => { + it("unwraps JSON string output instead of showing literal quotes", () => { + expect(toolOutputSections(JSON.stringify("Command running in background")).at(0)?.text).toBe("Command running in background"); + }); + + it("renders unwrapped text without JSON quotes", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Command running in background"); + expect(html).not.toContain(""Command running in background""); + }); + + it("wraps single-line output", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("tool-pre-text-singleline"); + expect(html).not.toContain("tool-pre-text-multiline"); + }); + + it("keeps multiline output unwrapped", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("tool-pre-text-multiline"); + expect(html).not.toContain("tool-pre-text-singleline"); + }); +}); 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..92d56ae --- /dev/null +++ b/packages/mobile-pwa/src/client/MobileClientProvider.tsx @@ -0,0 +1,331 @@ +import React from "react"; +import { CrewApi } from "@/api/client"; +import { classifyConnectError } from "@/client/classifyConnectError"; +import { ConnectionIssue } from "@/client/connectionIssue"; +import { connectPairedDevice, PairedProfile, registerPairing } from "@/remote/client"; +import { parsePairingOffer } from "@/remote/pairingOffer"; +import { JsonRpcPeer } from "@/remote/rpc"; +import { clearPairing, loadPairing, savePairing } from "@/storage/pairingStore"; +import { PWA_PAIRED_EVENT } from "@/pwa-install/events"; + +type Status = "loading" | "unpaired" | "connecting" | "online" | "error"; + +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 keepAliveIntervalMs = 10000; +const keepAliveTimeoutMs = 5000; + +function hasPairingSecretInHash(): boolean { + return new URLSearchParams(window.location.hash.replace(/^#/, "")).has("secret"); +} + +function resetNavigationToPair() { + if (window.location.hash !== "#/pair") window.location.hash = "/pair"; +} + +function showRemoteRevokedNotice() { + window.setTimeout(() => { + window.alert("This browser was unpaired from the Crew44 desktop. Pair again to reconnect."); + }, 250); +} + +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 keepAliveTimerRef = React.useRef | null>(null); + const mountedRef = React.useRef(true); + const statusRef = React.useRef("loading"); + const revokedRef = React.useRef(false); + const suppressRevokedNoticeRef = React.useRef(false); + + React.useEffect(() => { + statusRef.current = status; + }, [status]); + + 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; + setApi(null); + setError(message); + setConnectionIssue("desktop"); + setStatus("error"); + }, []); + + const showDesktopTimeout = React.useCallback((message = "The Crew44 desktop did not respond within 10 seconds.") => { + if (!mountedRef.current) return; + setApi(null); + setError(message); + setConnectionIssue("desktop_timeout"); + setStatus("error"); + }, []); + + const showRelayError = React.useCallback((message = "Relay connection failed") => { + if (!mountedRef.current) return; + setApi(null); + setError(message); + setConnectionIssue("relay"); + setStatus("error"); + }, []); + + 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 handleConnectError = React.useCallback((err: unknown) => { + const result = classifyConnectError(err); + if (result.issue === "relay") { + showRelayError(result.message); + return; + } + if (result.issue === "desktop_timeout") { + showDesktopTimeout(result.message); + return; + } + showDesktopOffline(result.message); + }, [showDesktopOffline, showDesktopTimeout, showRelayError]); + + const handleRemoteRevoked = React.useCallback(async () => { + revokedRef.current = true; + closeRpc(); + if (suppressRevokedNoticeRef.current) return; + await clearPairing(); + setProfile(null); + setConnectionIssue(""); + setError(""); + setStatus("unpaired"); + resetNavigationToPair(); + showRemoteRevokedNotice(); + }, [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 () => { + revokedRef.current = false; + closeRpc(); + 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); + setConnectionIssue(""); + setError(""); + setStatus("online"); + } catch (err) { + handleConnectError(err); + } + }, [classifyConnectionLoss, closeRpc, handleConnectError, handleRemoteRevoked, showDesktopOffline, startKeepAlive, stopKeepAlive]); + + React.useEffect(() => { + if (hasPairingSecretInHash()) { + let cancelled = false; + revokedRef.current = true; + closeRpc(); + clearPairing().then(() => { + if (cancelled || !mountedRef.current) return; + setProfile(null); + setConnectionIssue(""); + setError(""); + setStatus("unpaired"); + }).catch(() => { + if (!cancelled && mountedRef.current) setStatus("unpaired"); + }); + return () => { + cancelled = true; + mountedRef.current = false; + closeRpc(); + }; + } + connectStoredPairing(); + return () => { + mountedRef.current = false; + closeRpc(); + }; + }, [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) => { + let offer; + try { + offer = parsePairingOffer(text); + } catch (err) { + setError(err instanceof Error ? err.message : "Pairing failed"); + setConnectionIssue(""); + throw err; + } + + const oldApi = api; + const oldDeviceId = profile?.deviceId || ""; + setStatus("connecting"); + setError(""); + setConnectionIssue(""); + suppressRevokedNoticeRef.current = true; + revokedRef.current = true; + if (oldApi && oldDeviceId) { + await oldApi.deleteRemoteDevice(oldDeviceId).catch(() => {}); + } + closeRpc(); + await clearPairing(); + setProfile(null); + suppressRevokedNoticeRef.current = false; + revokedRef.current = false; + try { + const result = await registerPairing(offer, deviceName()); + await savePairing(result.profile, result.privateKey); + setProfile(result.profile); + await connectStoredPairing(); + window.dispatchEvent(new CustomEvent(PWA_PAIRED_EVENT)); + } catch (err) { + setStatus("unpaired"); + setError(err instanceof Error ? err.message : "Pairing failed"); + setConnectionIssue(""); + throw err; + } + }, [api, closeRpc, connectStoredPairing, profile]); + + const disconnect = React.useCallback(async () => { + const currentApi = api; + const desktopDeviceId = profile?.deviceId || ""; + const wasDesktopConnected = Boolean(currentApi && desktopDeviceId); + suppressRevokedNoticeRef.current = true; + 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"); + resetNavigationToPair(); + suppressRevokedNoticeRef.current = false; + }, [api, 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/client/classifyConnectError.ts b/packages/mobile-pwa/src/client/classifyConnectError.ts new file mode 100644 index 0000000..6f3e37e --- /dev/null +++ b/packages/mobile-pwa/src/client/classifyConnectError.ts @@ -0,0 +1,29 @@ +import type { ConnectionIssue } from "@/client/connectionIssue"; +import { DesktopOfflineError, DesktopTimeoutError, RelayConnectionError } from "@/remote/relay"; + +export type ConnectErrorClassification = { + issue: Exclude; + message: string; +}; + +export function classifyConnectError(err: unknown): ConnectErrorClassification { + if (err instanceof DesktopOfflineError) { + return { issue: "desktop", message: "Can't connect to the Crew44 desktop" }; + } + + if (err instanceof DesktopTimeoutError) { + return { + issue: "desktop_timeout", + message: "The Crew44 desktop did not respond within 10 seconds." + }; + } + + if (err instanceof RelayConnectionError) { + return { issue: "relay", message: err.message }; + } + + return { + issue: "desktop", + message: err instanceof Error ? err.message : "Can't connect to the Crew44 desktop" + }; +} diff --git a/packages/mobile-pwa/src/client/connectionIssue.ts b/packages/mobile-pwa/src/client/connectionIssue.ts new file mode 100644 index 0000000..9994b3f --- /dev/null +++ b/packages/mobile-pwa/src/client/connectionIssue.ts @@ -0,0 +1,7 @@ +export type ConnectionIssue = "" | "relay" | "desktop" | "desktop_timeout"; + +export function connectionIssueTitle(issue: ConnectionIssue): string { + if (issue === "relay") return "Relay connection issue"; + if (issue === "desktop_timeout") return "Crew44 desktop timed out"; + return "Can't connect to the Crew44 desktop"; +} diff --git a/packages/mobile-pwa/src/main.tsx b/packages/mobile-pwa/src/main.tsx new file mode 100644 index 0000000..837ab57 --- /dev/null +++ b/packages/mobile-pwa/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import { MobileClientProvider } from "@/client/MobileClientProvider"; +import "./styles.css"; + +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..08493b0 --- /dev/null +++ b/packages/mobile-pwa/src/pages/AgentPage.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { Agent } from "@/api/types"; +import { connectionIssueTitle } from "@/client/connectionIssue"; +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"} +
+
+ Skills + {agent.skill_ids.length ? agent.skill_ids.join(", ") : "None"} +
+
+ Instruction +

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

+
+
+ )} + + ); +} diff --git a/packages/mobile-pwa/src/pages/AgentsPage.tsx b/packages/mobile-pwa/src/pages/AgentsPage.tsx new file mode 100644 index 0000000..0b47a37 --- /dev/null +++ b/packages/mobile-pwa/src/pages/AgentsPage.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { Agent } from "@/api/types"; +import { connectionIssueTitle } from "@/client/connectionIssue"; +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..8b14b3e --- /dev/null +++ b/packages/mobile-pwa/src/pages/ChatPage.tsx @@ -0,0 +1,351 @@ +import React from "react"; +import { buildRenderableTimeline, mapBackendEvent, TimelineItem } from "@/api/events"; +import { Agent, BackendEvent, Chat } from "@/api/types"; +import { connectionIssueTitle } from "@/client/connectionIssue"; +import { useMobileClient } from "@/client/MobileClientProvider"; +import { Button, EmptyState, Header, IconButton, LoadingState, OfflineState, Screen } from "@/ui/Screen"; +import { BackIcon, SendIcon, StopIcon } from "@/ui/icons"; +import { AgentTargetPicker } from "@/ui/AgentTargetPicker"; +import { LoadedToolDetails, 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 ""; +} + +function mentionBounds(value: string, cursor: number) { + const before = value.slice(0, cursor); + const match = before.match(/(^|\s)@([^\s@]*)$/); + if (!match) return null; + const start = before.length - match[0].length + match[1].length; + return { start, end: cursor, query: match[2] || "" }; +} + +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 [cursor, setCursor] = React.useState(0); + 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 composerRef = React.useRef(null); + + const activeMention = React.useMemo(() => mentionBounds(draft, cursor), [cursor, draft]); + const mentionOptions = React.useMemo(() => { + if (!activeMention) return []; + const query = activeMention.query.toLowerCase(); + return agents + .filter(agent => agent.name.toLowerCase().includes(query)) + .slice(0, 6); + }, [activeMention, agents]); + const renderItems = React.useMemo(() => buildRenderableTimeline(items), [items]); + const hasTimelineError = React.useMemo(() => renderItems.some(item => item.kind === "error"), [renderItems]); + + 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 (!renderItems.length) return; + if (!didInitialScrollRef.current || shouldStickToBottomRef.current) { + scrollToBottom(!didInitialScrollRef.current ? false : true); + didInitialScrollRef.current = true; + } + }, [renderItems, 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(""); + setCursor(0); + 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 updateCursor = React.useCallback(() => { + const el = composerRef.current; + if (!el) return; + setCursor(el.selectionStart || 0); + }, []); + + const selectMention = React.useCallback((agent: Agent) => { + if (!activeMention) return; + const next = `${draft.slice(0, activeMention.start)}@${agent.name} ${draft.slice(activeMention.end)}`; + const nextCursor = activeMention.start + agent.name.length + 2; + setDraft(next); + setCursor(nextCursor); + setTargetAgentId(agent.id); + requestAnimationFrame(() => { + const el = composerRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(nextCursor, nextCursor); + }); + }, [activeMention, draft]); + + const cancel = React.useCallback(async () => { + if (!client.api) return; + await client.api.cancelChat(chatId); + cleanupRef.current(); + setStreaming(false); + }, [chatId, client.api]); + + const loadToolDetails = React.useCallback(async (toolCallSeq: number): Promise => { + if (!client.api) throw new Error("Not connected"); + const details = await client.api.getToolDetails(chatId, toolCallSeq); + const call = mapBackendEvent(details.tool_call); + const result = details.tool_result ? mapBackendEvent(details.tool_result) : null; + if (!call || call.kind !== "tool") throw new Error("Tool call not found"); + if (result?.kind === "tool_result") { + return { + path: call.path, + input: call.input, + result: "ok", + output: result.output, + detail: result.output.slice(0, 120) + }; + } + return { + path: call.path, + input: call.input, + result: call.result, + output: call.output, + detail: call.detail + }; + }, [chatId, client.api]); + + const backToProject = React.useCallback(() => { + if (!chat?.project_id) return; + navigate(`/projects/${chat.project_id}`); + }, [chat?.project_id, navigate]); + + if (client.status === "error" && !client.api) { + return ( + +
} + /> + + + ); + } + + return ( + +
} + /> + {loading ? : error && items.length === 0 ? ( + + + + ) : ( + <> +
+ {renderItems.length === 0 ? ( + + ) : ( + + )} +
+ {error && !hasTimelineError ?

{error}

: null} + {streaming ?

Agent is working...

: null} + {mentionOptions.length > 0 ? ( +
+ {mentionOptions.map(agent => ( + + ))} +
+ ) : null} +
{ event.preventDefault(); send().catch(() => {}); }}> +