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 (
+
+
+ );
+}
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 (
+
+
+ );
+ }
+
+ return (
+
+
+ );
+}
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}
+
+ >
+ )}
+
+ );
+}
diff --git a/packages/mobile-pwa/src/pages/HomePage.tsx b/packages/mobile-pwa/src/pages/HomePage.tsx
new file mode 100644
index 0000000..011366c
--- /dev/null
+++ b/packages/mobile-pwa/src/pages/HomePage.tsx
@@ -0,0 +1,96 @@
+import React from "react";
+import { Project } from "@/api/types";
+import { connectionIssueTitle } from "@/client/connectionIssue";
+import { useMobileClient } from "@/client/MobileClientProvider";
+import { Button, EmptyState, Header, LoadingState, OfflineState, Row, Screen } from "@/ui/Screen";
+import { MoreIcon } from "@/ui/icons";
+
+export function HomePage({ navigate }: { navigate: (path: string) => void }) {
+ const client = useMobileClient();
+ const [projects, setProjects] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState("");
+ const [menuOpen, setMenuOpen] = React.useState(false);
+
+ const load = React.useCallback(async () => {
+ if (!client.api) return;
+ setLoading(true);
+ setError("");
+ try {
+ setProjects(await client.api.listProjects());
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load projects");
+ } finally {
+ setLoading(false);
+ }
+ }, [client.api]);
+
+ React.useEffect(() => {
+ load();
+ }, [load]);
+
+ const confirmUnpair = React.useCallback(() => {
+ const confirmed = window.confirm("Unpair this phone?\n\nThis removes the mobile pairing from this phone and, while connected, from desktop too.");
+ if (!confirmed) return;
+ client.disconnect().catch(() => {});
+ }, [client]);
+
+ if (client.status === "error" && !client.api) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ {menuOpen ?
+ );
+}
diff --git a/packages/mobile-pwa/src/pages/PairPage.tsx b/packages/mobile-pwa/src/pages/PairPage.tsx
new file mode 100644
index 0000000..0fc6092
--- /dev/null
+++ b/packages/mobile-pwa/src/pages/PairPage.tsx
@@ -0,0 +1,156 @@
+import React from "react";
+import { BrowserQRCodeReader, IScannerControls } from "@zxing/browser";
+import { useMobileClient } from "@/client/MobileClientProvider";
+import { pairingSecretFromText } from "@/remote/pairingOffer";
+import { Button, Header, Screen } from "@/ui/Screen";
+import { CameraIcon } from "@/ui/icons";
+
+const cameraIdleMs = 60000;
+const pairSecretParam = "secret";
+
+function pairSecretFromLocation(): string {
+ const hash = window.location.hash;
+ if (!new URLSearchParams(hash.replace(/^#/, "")).has(pairSecretParam)) return "";
+ return pairingSecretFromText(hash);
+}
+
+export function PairPage() {
+ const client = useMobileClient();
+ const [manualText, setManualText] = React.useState("");
+ const [error, setError] = React.useState("");
+ const [pairing, setPairing] = React.useState(false);
+ const [scanning, setScanning] = React.useState(false);
+ const [cameraPaused, setCameraPaused] = React.useState(false);
+ const [urlSecret, setUrlSecret] = React.useState(pairSecretFromLocation);
+ const videoRef = React.useRef(null);
+ const controlsRef = React.useRef(null);
+ const idleTimerRef = React.useRef | null>(null);
+ const consumedUrlSecretRef = React.useRef("");
+
+ const stopIdleTimer = React.useCallback(() => {
+ if (idleTimerRef.current) {
+ clearTimeout(idleTimerRef.current);
+ idleTimerRef.current = null;
+ }
+ }, []);
+
+ const stopScanner = React.useCallback(() => {
+ stopIdleTimer();
+ controlsRef.current?.stop();
+ controlsRef.current = null;
+ setScanning(false);
+ }, [stopIdleTimer]);
+
+ React.useEffect(() => stopScanner, [stopScanner]);
+
+ React.useEffect(() => {
+ stopIdleTimer();
+ if (!scanning || pairing) return undefined;
+ idleTimerRef.current = setTimeout(() => {
+ controlsRef.current?.stop();
+ controlsRef.current = null;
+ setScanning(false);
+ setCameraPaused(true);
+ }, cameraIdleMs);
+ return stopIdleTimer;
+ }, [pairing, scanning, stopIdleTimer]);
+
+ const pair = React.useCallback(async (raw: string) => {
+ const text = raw.trim();
+ if (!text || pairing) return;
+ setPairing(true);
+ setError("");
+ stopScanner();
+ try {
+ await client.pairWithQrText(text);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Pairing failed");
+ } finally {
+ setPairing(false);
+ }
+ }, [client, pairing, stopScanner]);
+
+ React.useEffect(() => {
+ const onHashChange = () => setUrlSecret(pairSecretFromLocation());
+ window.addEventListener("hashchange", onHashChange);
+ return () => window.removeEventListener("hashchange", onHashChange);
+ }, []);
+
+ React.useEffect(() => {
+ const secret = urlSecret;
+ if (!secret || consumedUrlSecretRef.current === secret) return;
+ consumedUrlSecretRef.current = secret;
+ setManualText(secret);
+ window.history.replaceState(null, "", "/");
+ pair(secret).catch(() => {});
+ }, [pair, urlSecret]);
+
+ const startScanner = React.useCallback(async () => {
+ if (!videoRef.current || scanning) return;
+ setError("");
+ setCameraPaused(false);
+ setScanning(true);
+ try {
+ const reader = new BrowserQRCodeReader();
+ controlsRef.current = await reader.decodeFromVideoDevice(undefined, videoRef.current, result => {
+ const text = result?.getText();
+ if (text) pair(text).catch(() => {});
+ });
+ } catch (err) {
+ setScanning(false);
+ setError(err instanceof Error ? err.message : "Camera could not start");
+ }
+ }, [pair, scanning]);
+
+ const unpairNotice = client.error.startsWith("Also unpair") ? client.error : "";
+ const pairError = error || (unpairNotice ? "" : client.error);
+
+ return (
+
+
+
+ Use your phone camera to scan the Pair Mobile QR, or scan it here if you already opened Crew44 Mobile.
+
+
+ {!scanning ? (
+
+ {pairing ? (
+ <>
+
+ Pairing...
+ >
+ ) : cameraPaused ? (
+ <>
+ Camera paused.
+ Tap the scan area to resume.
+ >
+ ) : (
+ <>
+
+ Start camera
+ >
+ )}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/packages/mobile-pwa/src/pages/ProjectPage.tsx b/packages/mobile-pwa/src/pages/ProjectPage.tsx
new file mode 100644
index 0000000..86707f5
--- /dev/null
+++ b/packages/mobile-pwa/src/pages/ProjectPage.tsx
@@ -0,0 +1,173 @@
+import React from "react";
+import { ChatIndexEntry, Project } from "@/api/types";
+import { connectionIssueTitle } from "@/client/connectionIssue";
+import { useMobileClient } from "@/client/MobileClientProvider";
+import { Button, EmptyState, Header, IconButton, LoadingState, OfflineState, Row, Screen } from "@/ui/Screen";
+import { BackIcon } from "@/ui/icons";
+
+const CHAT_PAGE_SIZE = 30;
+
+function chatId(chat: ChatIndexEntry): string {
+ return chat.chat_id || chat.id || "";
+}
+
+function chatTime(chat: ChatIndexEntry): number {
+ const value = new Date(chat.updated_at || 0).getTime();
+ return Number.isFinite(value) ? value : 0;
+}
+
+function sortRecentFirst(chats: ChatIndexEntry[]): ChatIndexEntry[] {
+ return chats.slice().sort((a, b) => chatTime(b) - chatTime(a));
+}
+
+function appendUnique(prev: ChatIndexEntry[], next: ChatIndexEntry[]): ChatIndexEntry[] {
+ const seen = new Set(prev.map(chatId));
+ const merged = prev.slice();
+ for (const chat of next) {
+ const id = chatId(chat);
+ if (!id || seen.has(id)) continue;
+ seen.add(id);
+ merged.push(chat);
+ }
+ return sortRecentFirst(merged);
+}
+
+function isRunningChat(chat: ChatIndexEntry): boolean {
+ return chat.status === "running" || chat.status === "streaming";
+}
+
+function chatSubtitle(chat: ChatIndexEntry): string {
+ const updatedAt = new Date(chat.updated_at).toLocaleString();
+ return isRunningChat(chat) ? `Running · ${updatedAt}` : `Updated ${updatedAt}`;
+}
+
+export function ProjectPage({
+ projectId,
+ navigate
+}: {
+ projectId: string;
+ navigate: (path: string) => void;
+}) {
+ const client = useMobileClient();
+ const [projects, setProjects] = React.useState([]);
+ const [chats, setChats] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [loadingMore, setLoadingMore] = React.useState(false);
+ const [hasMore, setHasMore] = React.useState(true);
+ const [creating, setCreating] = React.useState(false);
+ const [error, setError] = React.useState("");
+ const listRef = React.useRef(null);
+
+ const project = projects.find(item => item.id === projectId);
+
+ const load = React.useCallback(async () => {
+ if (!client.api || !projectId) return;
+ setLoading(true);
+ setError("");
+ try {
+ const [nextProjects, nextChats] = await Promise.all([
+ client.api.listProjects(),
+ client.api.listProjectChats(projectId, { limit: CHAT_PAGE_SIZE, offset: 0 })
+ ]);
+ setProjects(nextProjects);
+ setChats(sortRecentFirst(nextChats));
+ setHasMore(nextChats.length === CHAT_PAGE_SIZE);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load chats");
+ } finally {
+ setLoading(false);
+ }
+ }, [client.api, projectId]);
+
+ const loadMore = React.useCallback(async () => {
+ if (!client.api || loading || loadingMore || !hasMore) return;
+ setLoadingMore(true);
+ try {
+ const nextChats = await client.api.listProjectChats(projectId, {
+ limit: CHAT_PAGE_SIZE,
+ offset: chats.length
+ });
+ setChats(prev => appendUnique(prev, nextChats));
+ setHasMore(nextChats.length === CHAT_PAGE_SIZE);
+ setError("");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load more chats");
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [chats.length, client.api, hasMore, loading, loadingMore, projectId]);
+
+ React.useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleScroll = React.useCallback(() => {
+ const el = listRef.current;
+ if (!el || loading || loadingMore || !hasMore) return;
+ const distanceFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
+ if (distanceFromBottom < 160) loadMore().catch(() => {});
+ }, [hasMore, loadMore, loading, loadingMore]);
+
+ const createChat = React.useCallback(async () => {
+ if (!client.api || !projectId) return;
+ const mainAgentId = project?.main_agent_id;
+ if (!mainAgentId) {
+ setError("This project does not have a main agent.");
+ return;
+ }
+ setCreating(true);
+ setError("");
+ try {
+ const chat = await client.api.createChat(projectId, "Mobile chat", mainAgentId);
+ navigate(`/chats/${chat.id}`);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create chat");
+ } finally {
+ setCreating(false);
+ }
+ }, [client.api, navigate, project, projectId]);
+
+ if (client.status === "error" && !client.api) {
+ return (
+
+
+ );
+ }
+
+ return (
+
+
+ );
+}
diff --git a/packages/mobile-pwa/src/pwa-install/PwaInstallPromptController.tsx b/packages/mobile-pwa/src/pwa-install/PwaInstallPromptController.tsx
new file mode 100644
index 0000000..43ae1f6
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/PwaInstallPromptController.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { hasHandledPwaInstallPrompt, markPwaInstallPromptHandled } from "./dismissal";
+import { PWA_PAIRED_EVENT } from "./events";
+import { AndroidNonChromeInstallPrompt } from "./devices/android-non-chrome/AndroidNonChromeInstallPrompt";
+import { detectInstallTarget, isStandalonePwa } from "./devices/detectInstallTarget";
+import { IosLegacyInstallPrompt } from "./devices/ios-legacy/IosLegacyInstallPrompt";
+import { IosModernInstallPrompt } from "./devices/ios-modern/IosModernInstallPrompt";
+import { BeforeInstallPromptEvent, InstallTarget } from "./devices/types";
+
+export function PwaInstallPromptController() {
+ const [target, setTarget] = React.useState("unsupported");
+ const deferredPromptRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (isStandalonePwa()) {
+ markPwaInstallPromptHandled();
+ return;
+ }
+
+ const onBeforeInstallPrompt = (event: Event) => {
+ event.preventDefault();
+ if (hasHandledPwaInstallPrompt()) return;
+ deferredPromptRef.current = event as BeforeInstallPromptEvent;
+ };
+ const onAppInstalled = () => {
+ markPwaInstallPromptHandled();
+ setTarget("unsupported");
+ deferredPromptRef.current = null;
+ };
+ window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
+ window.addEventListener("appinstalled", onAppInstalled);
+ return () => {
+ window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
+ window.removeEventListener("appinstalled", onAppInstalled);
+ };
+ }, []);
+
+ React.useEffect(() => {
+ if (target !== "unsupported") markPwaInstallPromptHandled();
+ }, [target]);
+
+ React.useEffect(() => {
+ const onPaired = () => {
+ if (isStandalonePwa()) {
+ markPwaInstallPromptHandled();
+ return;
+ }
+ if (hasHandledPwaInstallPrompt()) return;
+
+ const nextTarget = detectInstallTarget();
+ if (nextTarget === "android-chrome") {
+ const prompt = deferredPromptRef.current;
+ if (!prompt) return;
+ markPwaInstallPromptHandled();
+ prompt.prompt().then(() => prompt.userChoice).catch(() => {}).finally(() => {
+ if (deferredPromptRef.current === prompt) deferredPromptRef.current = null;
+ });
+ return;
+ }
+ if (nextTarget !== "unsupported") setTarget(nextTarget);
+ };
+ window.addEventListener(PWA_PAIRED_EVENT, onPaired);
+ return () => window.removeEventListener(PWA_PAIRED_EVENT, onPaired);
+ }, []);
+
+ const closePrompt = () => {
+ setTarget("unsupported");
+ };
+
+ if (target === "ios-modern") return ;
+ if (target === "ios-legacy") return ;
+ if (target === "android-non-chrome") return ;
+ return null;
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/android-non-chrome/AndroidNonChromeInstallPrompt.css b/packages/mobile-pwa/src/pwa-install/devices/android-non-chrome/AndroidNonChromeInstallPrompt.css
new file mode 100644
index 0000000..0a652c6
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/android-non-chrome/AndroidNonChromeInstallPrompt.css
@@ -0,0 +1,130 @@
+.android-non-chrome-install-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 18px;
+ background: rgba(28, 26, 23, 0.48);
+}
+
+.android-non-chrome-install-sheet {
+ position: relative;
+ width: min(100%, 360px);
+ border: 1px solid #d7d0bd;
+ border-radius: 8px;
+ background: #fcfbf7;
+ color: #1c1a17;
+ box-shadow: 0 22px 70px rgba(28, 26, 23, 0.26);
+ padding: 22px 18px 18px;
+ text-align: center;
+}
+
+.android-non-chrome-install-sheet h2 {
+ margin: 10px 34px 6px;
+ font-size: 20px;
+ line-height: 1.2;
+}
+
+.android-non-chrome-install-sheet p {
+ margin: 0 auto 16px;
+ max-width: 285px;
+ color: #5c544b;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.android-non-chrome-install-icon {
+ width: 62px;
+ height: 62px;
+ border-radius: 14px;
+ box-shadow: 0 0 0 1px rgba(28, 26, 23, 0.12);
+}
+
+.android-non-chrome-browser {
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fffef8;
+ overflow: hidden;
+}
+
+.android-non-chrome-bar {
+ min-height: 48px;
+ border-bottom: 1px solid #e6dfcc;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 10px 8px 14px;
+ color: #807972;
+ font-size: 13px;
+}
+
+.android-non-chrome-bar strong {
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ color: #20744a;
+ background: #edf8f1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.android-non-chrome-menu {
+ padding: 10px;
+ display: grid;
+ gap: 8px;
+}
+
+.android-non-chrome-menu div {
+ min-height: 44px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fcfbf7;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 11px;
+ text-align: left;
+ font-size: 14px;
+ font-weight: 650;
+}
+
+.android-non-chrome-menu svg,
+.android-non-chrome-bar svg,
+.pwa-install-close svg {
+ width: 20px;
+ height: 20px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.7;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ flex-shrink: 0;
+}
+
+.android-non-chrome-bar circle {
+ fill: currentColor;
+ stroke: none;
+}
+
+.android-non-chrome-menu svg {
+ color: #20744a;
+}
+
+.pwa-install-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ width: 32px;
+ height: 32px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fffef8;
+ color: #5c544b;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/android-non-chrome/AndroidNonChromeInstallPrompt.tsx b/packages/mobile-pwa/src/pwa-install/devices/android-non-chrome/AndroidNonChromeInstallPrompt.tsx
new file mode 100644
index 0000000..c739168
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/android-non-chrome/AndroidNonChromeInstallPrompt.tsx
@@ -0,0 +1,51 @@
+import { InstallCloseButton, InstallPromptProps } from "../types";
+import "./AndroidNonChromeInstallPrompt.css";
+
+function MenuIcon() {
+ return (
+
+ );
+}
+
+function HomeIcon() {
+ return (
+
+ );
+}
+
+export function AndroidNonChromeInstallPrompt({ onClose }: InstallPromptProps) {
+ return (
+
+
+
+
+ Install Crew44 Mobile
+ Open the browser menu, then choose Install app or Add to Home screen.
+
+
+ mobileapp.crew44.io
+
+
+
+
+
+ Install app
+
+
+
+ Add to Home screen
+
+
+
+
+
+ );
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/detectInstallTarget.ts b/packages/mobile-pwa/src/pwa-install/devices/detectInstallTarget.ts
new file mode 100644
index 0000000..e7f8fd3
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/detectInstallTarget.ts
@@ -0,0 +1,35 @@
+import { InstallTarget } from "./types";
+
+function isIos(userAgent: string): boolean {
+ const platform = navigator.platform || "";
+ return /iPad|iPhone|iPod/.test(userAgent) || (platform === "MacIntel" && navigator.maxTouchPoints > 1);
+}
+
+function iosMajorVersion(userAgent: string): number {
+ const match = userAgent.match(/OS (\d+)_/);
+ return match ? Number(match[1]) : 17;
+}
+
+export function isStandalonePwa(): boolean {
+ const nav = navigator as Navigator & { standalone?: boolean };
+ return window.matchMedia("(display-mode: standalone)").matches || nav.standalone === true;
+}
+
+export function detectInstallTarget(): InstallTarget {
+ if (isStandalonePwa()) return "unsupported";
+
+ const userAgent = navigator.userAgent;
+ const ios = isIos(userAgent);
+ const android = /Android/i.test(userAgent);
+
+ if (ios) {
+ return iosMajorVersion(userAgent) < 13 ? "ios-legacy" : "ios-modern";
+ }
+
+ if (android) {
+ const chrome = /Chrome|CriOS/i.test(userAgent) && !/EdgA|SamsungBrowser|Firefox|FxiOS|OPR\//i.test(userAgent);
+ return chrome ? "android-chrome" : "android-non-chrome";
+ }
+
+ return "unsupported";
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/ios-legacy/IosLegacyInstallPrompt.css b/packages/mobile-pwa/src/pwa-install/devices/ios-legacy/IosLegacyInstallPrompt.css
new file mode 100644
index 0000000..039bff9
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/ios-legacy/IosLegacyInstallPrompt.css
@@ -0,0 +1,135 @@
+.ios-legacy-install-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 18px;
+ background: rgba(28, 26, 23, 0.48);
+}
+
+.ios-legacy-install-sheet {
+ position: relative;
+ width: min(100%, 350px);
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fcfbf7;
+ color: #1c1a17;
+ box-shadow: 0 22px 70px rgba(28, 26, 23, 0.26);
+ padding: 22px 18px 18px;
+ text-align: center;
+}
+
+.ios-legacy-install-sheet h2 {
+ margin: 10px 34px 6px;
+ font-size: 20px;
+ line-height: 1.2;
+}
+
+.ios-legacy-install-sheet p {
+ margin: 0 auto 15px;
+ max-width: 275px;
+ color: #5c544b;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.ios-legacy-install-icon {
+ width: 62px;
+ height: 62px;
+ border-radius: 14px;
+ box-shadow: 0 0 0 1px rgba(28, 26, 23, 0.12);
+}
+
+.ios-legacy-phone {
+ height: 138px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #fffef8;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.ios-legacy-page {
+ flex: 1;
+ margin: 12px;
+ border-radius: 8px;
+ background: linear-gradient(135deg, #f0ead8 0%, #d9e7de 100%);
+}
+
+.ios-legacy-toolbar {
+ height: 44px;
+ border-top: 1px solid #e6dfcc;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ padding: 0 18px;
+}
+
+.ios-legacy-toolbar > span:not(.ios-legacy-share) {
+ height: 5px;
+ border-radius: 5px;
+ background: #d8cfb8;
+}
+
+.ios-legacy-share,
+.ios-legacy-action-row span {
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ color: #1769d1;
+ background: #edf5ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.ios-legacy-share svg,
+.ios-legacy-action-row svg,
+.pwa-install-close svg {
+ width: 20px;
+ height: 20px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.7;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.ios-legacy-action-row {
+ min-height: 48px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fffef8;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 8px 12px;
+ text-align: left;
+}
+
+.ios-legacy-action-row strong {
+ font-size: 15px;
+ line-height: 1.2;
+}
+
+.pwa-install-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ width: 32px;
+ height: 32px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fffef8;
+ color: #5c544b;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/ios-legacy/IosLegacyInstallPrompt.tsx b/packages/mobile-pwa/src/pwa-install/devices/ios-legacy/IosLegacyInstallPrompt.tsx
new file mode 100644
index 0000000..1e5b80c
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/ios-legacy/IosLegacyInstallPrompt.tsx
@@ -0,0 +1,45 @@
+import { InstallCloseButton, InstallPromptProps } from "../types";
+import "./IosLegacyInstallPrompt.css";
+
+function ShareIcon() {
+ return (
+
+ );
+}
+
+function PlusIcon() {
+ return (
+
+ );
+}
+
+export function IosLegacyInstallPrompt({ onClose }: InstallPromptProps) {
+ return (
+
+
+
+
+ Install Crew44 Mobile
+ Tap the share button in the bottom toolbar, then tap Add to Home Screen.
+
+
+
+
+ );
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/ios-modern/IosModernInstallPrompt.css b/packages/mobile-pwa/src/pwa-install/devices/ios-modern/IosModernInstallPrompt.css
new file mode 100644
index 0000000..d09f514
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/ios-modern/IosModernInstallPrompt.css
@@ -0,0 +1,118 @@
+.ios-modern-install-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ padding: 18px;
+ padding-bottom: calc(18px + env(safe-area-inset-bottom));
+ background: rgba(28, 26, 23, 0.48);
+}
+
+.ios-modern-install-sheet {
+ position: relative;
+ width: min(100%, 360px);
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fcfbf7;
+ color: #1c1a17;
+ box-shadow: 0 22px 70px rgba(28, 26, 23, 0.26);
+ padding: 22px 18px 18px;
+ text-align: center;
+}
+
+.ios-modern-install-sheet h2 {
+ margin: 10px 34px 6px;
+ font-size: 20px;
+ line-height: 1.2;
+}
+
+.ios-modern-install-sheet p {
+ margin: 0 auto 16px;
+ max-width: 270px;
+ color: #5c544b;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.ios-modern-install-icon {
+ width: 62px;
+ height: 62px;
+ border-radius: 14px;
+ box-shadow: 0 0 0 1px rgba(28, 26, 23, 0.12);
+}
+
+.ios-modern-install-steps {
+ display: grid;
+ gap: 10px;
+}
+
+.ios-modern-browser-bar,
+.ios-modern-action-row {
+ min-height: 48px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fffef8;
+ display: flex;
+ align-items: center;
+}
+
+.ios-modern-browser-bar {
+ justify-content: space-between;
+ padding: 8px 10px 8px 14px;
+ color: #807972;
+ font-size: 13px;
+}
+
+.ios-modern-share,
+.ios-modern-action-row span {
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ color: #1769d1;
+ background: #edf5ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.ios-modern-share svg,
+.ios-modern-action-row svg,
+.pwa-install-close svg {
+ width: 20px;
+ height: 20px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.7;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.ios-modern-action-row {
+ gap: 11px;
+ padding: 8px 12px;
+ text-align: left;
+}
+
+.ios-modern-action-row strong {
+ font-size: 15px;
+ line-height: 1.2;
+}
+
+.pwa-install-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ width: 32px;
+ height: 32px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fffef8;
+ color: #5c544b;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/ios-modern/IosModernInstallPrompt.tsx b/packages/mobile-pwa/src/pwa-install/devices/ios-modern/IosModernInstallPrompt.tsx
new file mode 100644
index 0000000..a94d7b4
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/ios-modern/IosModernInstallPrompt.tsx
@@ -0,0 +1,43 @@
+import { InstallCloseButton, InstallPromptProps } from "../types";
+import "./IosModernInstallPrompt.css";
+
+function ShareIcon() {
+ return (
+
+ );
+}
+
+function AddIcon() {
+ return (
+
+ );
+}
+
+export function IosModernInstallPrompt({ onClose }: InstallPromptProps) {
+ return (
+
+
+
+
+ Install Crew44 Mobile
+ Tap the Safari share button, then choose Add to Home Screen.
+
+
+ mobileapp.crew44.io
+
+
+
+
+
+
+ );
+}
diff --git a/packages/mobile-pwa/src/pwa-install/devices/types.tsx b/packages/mobile-pwa/src/pwa-install/devices/types.tsx
new file mode 100644
index 0000000..435399b
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/devices/types.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+
+export type InstallTarget = "ios-modern" | "ios-legacy" | "android-non-chrome" | "android-chrome" | "unsupported";
+
+export interface InstallPromptProps {
+ onClose: () => void;
+}
+
+export interface BeforeInstallPromptEvent extends Event {
+ readonly platforms: string[];
+ readonly userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
+ prompt: () => Promise;
+}
+
+export function InstallCloseButton({ onClose }: { onClose: () => void }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/mobile-pwa/src/pwa-install/dismissal.ts b/packages/mobile-pwa/src/pwa-install/dismissal.ts
new file mode 100644
index 0000000..35a5225
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/dismissal.ts
@@ -0,0 +1,17 @@
+const PWA_INSTALL_DISMISSED_KEY = "crew44:pwa-install-dismissed";
+
+export function hasHandledPwaInstallPrompt(): boolean {
+ try {
+ return window.localStorage.getItem(PWA_INSTALL_DISMISSED_KEY) === "1";
+ } catch {
+ return false;
+ }
+}
+
+export function markPwaInstallPromptHandled(): void {
+ try {
+ window.localStorage.setItem(PWA_INSTALL_DISMISSED_KEY, "1");
+ } catch {
+ // If storage is unavailable, the prompt remains session-scoped.
+ }
+}
diff --git a/packages/mobile-pwa/src/pwa-install/events.ts b/packages/mobile-pwa/src/pwa-install/events.ts
new file mode 100644
index 0000000..9f88137
--- /dev/null
+++ b/packages/mobile-pwa/src/pwa-install/events.ts
@@ -0,0 +1 @@
+export const PWA_PAIRED_EVENT = "crew44:pwa-paired";
diff --git a/packages/mobile-pwa/src/remote/bytes.ts b/packages/mobile-pwa/src/remote/bytes.ts
new file mode 100644
index 0000000..56799bc
--- /dev/null
+++ b/packages/mobile-pwa/src/remote/bytes.ts
@@ -0,0 +1,70 @@
+export function utf8(value: string): Uint8Array {
+ return new TextEncoder().encode(value);
+}
+
+export function concatBytes(...chunks: Uint8Array[]): Uint8Array {
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return out;
+}
+
+export function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
+ return diff === 0;
+}
+
+export function base64RawEncode(bytes: Uint8Array): string {
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let out = "";
+ for (let i = 0; i < bytes.length; i += 3) {
+ const a = bytes[i];
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
+ const n = (a << 16) | (b << 8) | c;
+ out += chars[(n >>> 18) & 63];
+ out += chars[(n >>> 12) & 63];
+ if (i + 1 < bytes.length) out += chars[(n >>> 6) & 63];
+ if (i + 2 < bytes.length) out += chars[n & 63];
+ }
+ return out;
+}
+
+export function base64RawDecode(value: string): Uint8Array {
+ const normalized = value.trim().replace(/-/g, "+").replace(/_/g, "/");
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
+ throw new Error("Invalid base64 value");
+ }
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
+ if (typeof atob !== "function") {
+ throw new Error("Base64 decoding is not available in this runtime");
+ }
+ const binary = atob(padded);
+ const out = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
+ return out;
+}
+
+export function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
+ const out = new ArrayBuffer(bytes.byteLength);
+ new Uint8Array(out).set(bytes);
+ return out;
+}
+
+export async function bytesFromWebSocketData(data: unknown): Promise {
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
+ if (ArrayBuffer.isView(data)) {
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ }
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
+ return new Uint8Array(await data.arrayBuffer());
+ }
+ if (typeof data === "string") return utf8(data);
+ throw new Error("Unsupported WebSocket frame type");
+}
diff --git a/packages/mobile-pwa/src/remote/client.ts b/packages/mobile-pwa/src/remote/client.ts
new file mode 100644
index 0000000..276d2fc
--- /dev/null
+++ b/packages/mobile-pwa/src/remote/client.ts
@@ -0,0 +1,144 @@
+import { base64RawDecode, base64RawEncode, utf8 } from "./bytes";
+import { EncryptedFrameTransport } from "./frameTransport";
+import { DHKeyPair, generateDHKeyPair, NoiseInitiator, NoiseKeySource, publicKeyFromPrivate } from "./noise";
+import { PairingOffer } from "./pairingOffer";
+import { attachRpcSocket, JsonRpcPeer } from "./rpc";
+import { openRelaySocket, sendBinary, sendJSON, waitForBinaryMessage, waitForRelayReady } from "./relay";
+
+export interface PairedProfile {
+ serverId: string;
+ relayUrl: string;
+ desktopName?: string;
+ daemonPubKey: string;
+ deviceId: string;
+ deviceName: string;
+ devicePubKey: string;
+ pairedAt: string;
+}
+
+export interface PairingResult {
+ profile: PairedProfile;
+ privateKey: string;
+}
+
+const cryptoKeySource: NoiseKeySource = {
+ randomBytes(length: number) {
+ const bytes = new Uint8Array(length);
+ crypto.getRandomValues(bytes);
+ return bytes;
+ }
+};
+
+function keyPairFromPrivateBase64(privateKey: string): DHKeyPair {
+ const rawPrivateKey = base64RawDecode(privateKey);
+ return {
+ privateKey: rawPrivateKey,
+ publicKey: publicKeyFromPrivate(rawPrivateKey)
+ };
+}
+
+function makeDeviceKeyPair(): DHKeyPair {
+ return generateDHKeyPair(cryptoKeySource);
+}
+
+function rpcRequest(id: string, method: string, params: unknown): Uint8Array {
+ return utf8(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
+}
+
+async function readEncryptedResponse(transport: EncryptedFrameTransport, socket: WebSocket): Promise {
+ const message = await new Promise((resolve, reject) => {
+ const cleanup = () => {
+ socket.removeEventListener("message", onMessage);
+ socket.removeEventListener("error", onError);
+ socket.removeEventListener("close", onClose);
+ };
+ const onMessage = (event: MessageEvent) => {
+ cleanup();
+ resolve(event);
+ };
+ const onError = () => {
+ cleanup();
+ reject(new Error("Pairing socket failed"));
+ };
+ const onClose = () => {
+ cleanup();
+ reject(new Error("Pairing socket closed"));
+ };
+ socket.addEventListener("message", onMessage);
+ socket.addEventListener("error", onError);
+ socket.addEventListener("close", onClose);
+ });
+ const plaintext = await transport.decrypt(message.data);
+ return JSON.parse(new TextDecoder().decode(plaintext));
+}
+
+export async function registerPairing(offer: PairingOffer, deviceName: string): Promise {
+ const socket = await openRelaySocket(offer.relay_url, offer.server_id);
+ try {
+ await waitForRelayReady(socket);
+ sendJSON(socket, { type: "noise_init", mode: "pairing" });
+ const remoteStatic = base64RawDecode(offer.daemon_pubkey);
+ const noise = new NoiseInitiator("NK", remoteStatic, cryptoKeySource);
+ sendBinary(socket, noise.writeMessageA());
+ noise.readMessageB(await waitForBinaryMessage(socket));
+ const transport = new EncryptedFrameTransport(socket, noise.split());
+
+ const deviceKey = makeDeviceKeyPair();
+ const devicePubKey = base64RawEncode(deviceKey.publicKey);
+ transport.send(rpcRequest("pair_register", "remote.pair.register", {
+ pairing_id: offer.pairing_id,
+ pairing_secret: offer.pairing_secret,
+ device_name: deviceName,
+ device_pubkey: devicePubKey
+ }));
+
+ const response = await readEncryptedResponse(transport, socket);
+ if (response.error) throw new Error(response.error.message || "Pairing failed");
+ const device = response.result?.device;
+ if (!device?.device_id) throw new Error("Pairing response did not include a device");
+
+ return {
+ privateKey: base64RawEncode(deviceKey.privateKey),
+ profile: {
+ serverId: offer.server_id,
+ relayUrl: offer.relay_url,
+ desktopName: offer.desktop_name,
+ daemonPubKey: offer.daemon_pubkey,
+ deviceId: device.device_id,
+ deviceName: device.name || deviceName,
+ devicePubKey,
+ pairedAt: new Date().toISOString()
+ }
+ };
+ } finally {
+ socket.close();
+ }
+}
+
+export async function connectPairedDevice(
+ profile: PairedProfile,
+ privateKey: string,
+ onClose: (err: Error) => void,
+ onRevoked: () => void
+): Promise {
+ const socket = await openRelaySocket(profile.relayUrl, profile.serverId);
+ await waitForRelayReady(socket);
+ sendJSON(socket, { type: "noise_init", mode: "device" });
+
+ const localStatic = keyPairFromPrivateBase64(privateKey);
+ const noise = new NoiseInitiator(
+ "XK",
+ base64RawDecode(profile.daemonPubKey),
+ cryptoKeySource,
+ localStatic
+ );
+ sendBinary(socket, noise.writeMessageA());
+ noise.readMessageB(await waitForBinaryMessage(socket));
+ sendBinary(socket, noise.writeMessageC());
+
+ const transport = new EncryptedFrameTransport(socket, noise.split());
+ const peer = new JsonRpcPeer(transport);
+ peer.on("remote.device.revoked", onRevoked);
+ attachRpcSocket(peer, socket, onClose);
+ return peer;
+}
diff --git a/packages/mobile-pwa/src/remote/frameTransport.ts b/packages/mobile-pwa/src/remote/frameTransport.ts
new file mode 100644
index 0000000..0d9816c
--- /dev/null
+++ b/packages/mobile-pwa/src/remote/frameTransport.ts
@@ -0,0 +1,29 @@
+import { arrayBufferFromBytes, bytesFromWebSocketData } from "./bytes";
+import { CipherPair, decryptFrame, encryptFrame } from "./noise";
+
+export class EncryptedFrameTransport {
+ private sendNonce = 0;
+ private recvNonce = 0;
+
+ constructor(
+ private readonly socket: WebSocket,
+ private readonly ciphers: CipherPair
+ ) {}
+
+ send(plaintext: Uint8Array) {
+ const ciphertext = encryptFrame(this.ciphers.sendKey, this.sendNonce, plaintext);
+ this.sendNonce += 1;
+ this.socket.send(arrayBufferFromBytes(ciphertext));
+ }
+
+ async decrypt(data: unknown): Promise {
+ const ciphertext = await bytesFromWebSocketData(data);
+ const plaintext = decryptFrame(this.ciphers.recvKey, this.recvNonce, ciphertext);
+ this.recvNonce += 1;
+ return plaintext;
+ }
+
+ close() {
+ this.socket.close();
+ }
+}
diff --git a/packages/mobile-pwa/src/remote/noise.ts b/packages/mobile-pwa/src/remote/noise.ts
new file mode 100644
index 0000000..74a6dcb
--- /dev/null
+++ b/packages/mobile-pwa/src/remote/noise.ts
@@ -0,0 +1,188 @@
+import { x25519 } from "@noble/curves/ed25519.js";
+import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
+import { blake2s } from "@noble/hashes/blake2.js";
+import { hmac } from "@noble/hashes/hmac.js";
+import { concatBytes, utf8 } from "./bytes";
+
+const HASH_LEN = 32;
+const KEY_LEN = 32;
+const EMPTY = new Uint8Array(0);
+const PROTOCOL_NK = "Noise_NK_25519_ChaChaPoly_BLAKE2s";
+const PROTOCOL_XK = "Noise_XK_25519_ChaChaPoly_BLAKE2s";
+
+export interface DHKeyPair {
+ privateKey: Uint8Array;
+ publicKey: Uint8Array;
+}
+
+export interface CipherPair {
+ sendKey: Uint8Array;
+ recvKey: Uint8Array;
+}
+
+export interface NoiseKeySource {
+ randomBytes(length: number): Uint8Array;
+}
+
+export function publicKeyFromPrivate(privateKey: Uint8Array): Uint8Array {
+ return x25519.getPublicKey(privateKey);
+}
+
+export function generateDHKeyPair(source: NoiseKeySource): DHKeyPair {
+ const privateKey = source.randomBytes(KEY_LEN);
+ return {
+ privateKey,
+ publicKey: publicKeyFromPrivate(privateKey)
+ };
+}
+
+function hash(data: Uint8Array): Uint8Array {
+ return blake2s(data, { dkLen: HASH_LEN });
+}
+
+function hkdf(chainingKey: Uint8Array, inputKeyMaterial: Uint8Array, outputs: 2 | 3): Uint8Array[] {
+ const tempKey = hmac(blake2s, chainingKey, inputKeyMaterial);
+ const out1 = hmac(blake2s, tempKey, new Uint8Array([1]));
+ const out2 = hmac(blake2s, tempKey, concatBytes(out1, new Uint8Array([2])));
+ if (outputs === 2) return [out1, out2];
+ const out3 = hmac(blake2s, tempKey, concatBytes(out2, new Uint8Array([3])));
+ return [out1, out2, out3];
+}
+
+function nonceBytes(nonce: number): Uint8Array {
+ const out = new Uint8Array(12);
+ let value = BigInt(nonce);
+ for (let i = 0; i < 8; i += 1) {
+ out[4 + i] = Number(value & 0xffn);
+ value >>= 8n;
+ }
+ return out;
+}
+
+function dh(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array {
+ return x25519.getSharedSecret(privateKey, publicKey);
+}
+
+class CipherState {
+ private key: Uint8Array | null = null;
+ private nonce = 0;
+
+ initializeKey(key: Uint8Array | null) {
+ this.key = key;
+ this.nonce = 0;
+ }
+
+ encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array {
+ if (!this.key) return plaintext;
+ const cipher = chacha20poly1305(this.key, nonceBytes(this.nonce), ad);
+ this.nonce += 1;
+ return cipher.encrypt(plaintext);
+ }
+
+ decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array {
+ if (!this.key) return ciphertext;
+ const cipher = chacha20poly1305(this.key, nonceBytes(this.nonce), ad);
+ this.nonce += 1;
+ return cipher.decrypt(ciphertext);
+ }
+}
+
+class SymmetricState {
+ private cipher = new CipherState();
+ private chainingKey: Uint8Array;
+ private handshakeHash: Uint8Array;
+
+ constructor(protocolName: string) {
+ const protocol = utf8(protocolName);
+ if (protocol.length <= HASH_LEN) {
+ this.handshakeHash = new Uint8Array(HASH_LEN);
+ this.handshakeHash.set(protocol);
+ } else {
+ this.handshakeHash = hash(protocol);
+ }
+ this.chainingKey = this.handshakeHash;
+ }
+
+ mixHash(data: Uint8Array) {
+ this.handshakeHash = hash(concatBytes(this.handshakeHash, data));
+ }
+
+ mixKey(inputKeyMaterial: Uint8Array) {
+ const [nextChainingKey, tempKey] = hkdf(this.chainingKey, inputKeyMaterial, 2);
+ this.chainingKey = nextChainingKey;
+ this.cipher.initializeKey(tempKey);
+ }
+
+ encryptAndHash(plaintext: Uint8Array): Uint8Array {
+ const ciphertext = this.cipher.encryptWithAd(this.handshakeHash, plaintext);
+ this.mixHash(ciphertext);
+ return ciphertext;
+ }
+
+ decryptAndHash(ciphertext: Uint8Array): Uint8Array {
+ const plaintext = this.cipher.decryptWithAd(this.handshakeHash, ciphertext);
+ this.mixHash(ciphertext);
+ return plaintext;
+ }
+
+ split(): CipherPair {
+ const [sendKey, recvKey] = hkdf(this.chainingKey, EMPTY, 2);
+ return { sendKey, recvKey };
+ }
+}
+
+export class NoiseInitiator {
+ private state: SymmetricState;
+ private localStatic?: DHKeyPair;
+ private localEphemeral?: DHKeyPair;
+ private remoteEphemeral?: Uint8Array;
+
+ constructor(
+ pattern: "NK" | "XK",
+ private readonly remoteStatic: Uint8Array,
+ private readonly keySource: NoiseKeySource,
+ localStatic?: DHKeyPair
+ ) {
+ this.localStatic = localStatic;
+ this.state = new SymmetricState(pattern === "NK" ? PROTOCOL_NK : PROTOCOL_XK);
+ this.state.mixHash(EMPTY);
+ this.state.mixHash(remoteStatic);
+ }
+
+ writeMessageA(): Uint8Array {
+ this.localEphemeral = generateDHKeyPair(this.keySource);
+ this.state.mixHash(this.localEphemeral.publicKey);
+ this.state.mixKey(dh(this.localEphemeral.privateKey, this.remoteStatic));
+ return concatBytes(this.localEphemeral.publicKey, this.state.encryptAndHash(EMPTY));
+ }
+
+ readMessageB(message: Uint8Array) {
+ if (!this.localEphemeral) throw new Error("Noise message A has not been sent");
+ if (message.length < KEY_LEN) throw new Error("Noise responder message is too short");
+ this.remoteEphemeral = message.slice(0, KEY_LEN);
+ this.state.mixHash(this.remoteEphemeral);
+ this.state.mixKey(dh(this.localEphemeral.privateKey, this.remoteEphemeral));
+ this.state.decryptAndHash(message.slice(KEY_LEN));
+ }
+
+ writeMessageC(): Uint8Array {
+ if (!this.localStatic || !this.remoteEphemeral) {
+ throw new Error("Noise XK state is not ready for final message");
+ }
+ const encryptedStatic = this.state.encryptAndHash(this.localStatic.publicKey);
+ this.state.mixKey(dh(this.localStatic.privateKey, this.remoteEphemeral));
+ return concatBytes(encryptedStatic, this.state.encryptAndHash(EMPTY));
+ }
+
+ split(): CipherPair {
+ return this.state.split();
+ }
+}
+
+export function encryptFrame(key: Uint8Array, nonce: number, plaintext: Uint8Array): Uint8Array {
+ return chacha20poly1305(key, nonceBytes(nonce), EMPTY).encrypt(plaintext);
+}
+
+export function decryptFrame(key: Uint8Array, nonce: number, ciphertext: Uint8Array): Uint8Array {
+ return chacha20poly1305(key, nonceBytes(nonce), EMPTY).decrypt(ciphertext);
+}
diff --git a/packages/mobile-pwa/src/remote/pairingOffer.ts b/packages/mobile-pwa/src/remote/pairingOffer.ts
new file mode 100644
index 0000000..1c35a40
--- /dev/null
+++ b/packages/mobile-pwa/src/remote/pairingOffer.ts
@@ -0,0 +1,79 @@
+export const PAIRING_TYPE = "crew44-remote-pairing";
+export const PAIRING_SECRET_PARAM = "secret";
+
+export interface PairingOffer {
+ v: number;
+ type: string;
+ relay_url: string;
+ server_id: string;
+ desktop_name?: string;
+ daemon_pubkey: string;
+ pairing_id: string;
+ pairing_secret: string;
+ expires_at: string;
+}
+
+function requireString(value: unknown, name: string): string {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw new Error(`Pairing offer is missing ${name}`);
+ }
+ return value;
+}
+
+export function pairingSecretFromText(text: string): string {
+ const trimmed = text.trim();
+ const hashIndex = trimmed.indexOf("#");
+ if (hashIndex >= 0) {
+ const hashText = trimmed.slice(hashIndex + 1);
+ const secret = new URLSearchParams(hashText).get(PAIRING_SECRET_PARAM);
+ if (secret) return secret;
+ const marker = `${PAIRING_SECRET_PARAM}=`;
+ const markerIndex = hashText.indexOf(marker);
+ if (markerIndex >= 0) return decodePairingSecret(hashText.slice(markerIndex + marker.length));
+ }
+ const jsonStart = trimmed.indexOf("{");
+ const jsonEnd = trimmed.lastIndexOf("}");
+ if (jsonStart >= 0 && jsonEnd > jsonStart) return trimmed.slice(jsonStart, jsonEnd + 1);
+ return trimmed;
+}
+
+function decodePairingSecret(value: string): string {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+export function parsePairingOffer(text: string, now: Date = new Date()): PairingOffer {
+ let raw: unknown;
+ try {
+ raw = JSON.parse(pairingSecretFromText(text));
+ } catch {
+ throw new Error("Pairing QR is not valid JSON");
+ }
+ if (!raw || typeof raw !== "object") {
+ throw new Error("Pairing QR is not an object");
+ }
+ const obj = raw as Record;
+ if (obj.v !== 1) throw new Error("Unsupported pairing offer version");
+
+ const offer: PairingOffer = {
+ v: 1,
+ type: PAIRING_TYPE,
+ relay_url: requireString(obj.r, "r"),
+ server_id: requireString(obj.s, "s"),
+ desktop_name: typeof obj.n === "string" && obj.n.trim() ? obj.n.trim() : undefined,
+ daemon_pubkey: requireString(obj.k, "k"),
+ pairing_id: requireString(obj.p, "p"),
+ pairing_secret: requireString(obj.x, "x"),
+ expires_at: requireString(obj.e, "e")
+ };
+
+ const expiresAt = new Date(offer.expires_at);
+ if (Number.isNaN(expiresAt.getTime())) throw new Error("Pairing offer has invalid expiration");
+ if (expiresAt.getTime() <= now.getTime()) throw new Error("Pairing offer has expired");
+ if (!/^wss?:\/\//.test(offer.relay_url)) throw new Error("Pairing offer relay URL must use ws or wss");
+
+ return offer;
+}
diff --git a/packages/mobile-pwa/src/remote/relay.ts b/packages/mobile-pwa/src/remote/relay.ts
new file mode 100644
index 0000000..de8dbcf
--- /dev/null
+++ b/packages/mobile-pwa/src/remote/relay.ts
@@ -0,0 +1,178 @@
+import { arrayBufferFromBytes, bytesFromWebSocketData } from "./bytes";
+
+export class RelayConnectionError extends Error {
+ constructor(message = "Relay connection failed") {
+ super(message);
+ this.name = "RelayConnectionError";
+ }
+}
+
+export class DesktopOfflineError extends Error {
+ constructor(message = "Can't connect to the Crew44 desktop") {
+ super(message);
+ this.name = "DesktopOfflineError";
+ }
+}
+
+export class DesktopTimeoutError extends Error {
+ constructor(message = "The Crew44 desktop did not respond within 10 seconds.") {
+ super(message);
+ this.name = "DesktopTimeoutError";
+ }
+}
+
+export function buildRelayClientUrl(relayUrl: string, serverId: string): string {
+ return buildRelayUrl(relayUrl, serverId, "client");
+}
+
+export function buildRelayStatusUrl(relayUrl: string, serverId: string): string {
+ return buildRelayUrl(relayUrl, serverId, "status");
+}
+
+function buildRelayUrl(relayUrl: string, serverId: string, role: string): string {
+ const url = new URL(relayUrl);
+ if (!url.pathname || url.pathname === "/") url.pathname = "/relay";
+ url.searchParams.set("role", role);
+ url.searchParams.set("server_id", serverId);
+ return url.toString();
+}
+
+export function openRelaySocket(relayUrl: string, serverId: string): Promise {
+ return new Promise((resolve, reject) => {
+ const socket = new WebSocket(buildRelayClientUrl(relayUrl, serverId));
+ socket.binaryType = "arraybuffer";
+ const cleanup = () => {
+ socket.removeEventListener("open", onOpen);
+ socket.removeEventListener("error", onError);
+ socket.removeEventListener("close", onClose);
+ };
+ const onOpen = () => {
+ cleanup();
+ resolve(socket);
+ };
+ const onError = () => {
+ cleanup();
+ reject(new RelayConnectionError("Relay socket failed"));
+ };
+ const onClose = () => {
+ cleanup();
+ reject(new RelayConnectionError("Relay socket closed before opening"));
+ };
+ socket.addEventListener("open", onOpen);
+ socket.addEventListener("error", onError);
+ socket.addEventListener("close", onClose);
+ });
+}
+
+export async function checkRelayDesktopStatus(relayUrl: string, serverId: string): Promise<"desktop_online" | "desktop_offline" | "desktop_timeout"> {
+ const socket = await new Promise((resolve, reject) => {
+ const ws = new WebSocket(buildRelayStatusUrl(relayUrl, serverId));
+ const cleanup = () => {
+ ws.removeEventListener("open", onOpen);
+ ws.removeEventListener("error", onError);
+ ws.removeEventListener("close", onClose);
+ };
+ const onOpen = () => {
+ cleanup();
+ resolve(ws);
+ };
+ const onError = () => {
+ cleanup();
+ reject(new RelayConnectionError("Relay socket failed"));
+ };
+ const onClose = () => {
+ cleanup();
+ reject(new RelayConnectionError("Relay socket closed before opening"));
+ };
+ ws.addEventListener("open", onOpen);
+ ws.addEventListener("error", onError);
+ ws.addEventListener("close", onClose);
+ });
+ try {
+ const status = await waitForRelayStatus(socket);
+ return status;
+ } finally {
+ socket.close();
+ }
+}
+
+export function waitForRelayReady(socket: WebSocket): Promise {
+ return waitForRelayStatus(socket).then(status => {
+ if (status === "desktop_offline") throw new DesktopOfflineError();
+ if (status === "desktop_timeout") throw new DesktopTimeoutError();
+ });
+}
+
+function waitForRelayStatus(socket: WebSocket): Promise<"desktop_online" | "desktop_offline" | "desktop_timeout"> {
+ return new Promise((resolve, reject) => {
+ const cleanup = () => {
+ socket.removeEventListener("message", onMessage);
+ socket.removeEventListener("error", onError);
+ socket.removeEventListener("close", onClose);
+ };
+ const onMessage = async (event: MessageEvent) => {
+ cleanup();
+ try {
+ const text = typeof event.data === "string"
+ ? event.data
+ : new TextDecoder().decode(await bytesFromWebSocketData(event.data));
+ const data = JSON.parse(text) as { type?: string };
+ if (data.type === "desktop_online" || data.type === "desktop_offline" || data.type === "desktop_timeout") {
+ resolve(data.type);
+ return;
+ }
+ reject(new RelayConnectionError("Relay returned an unknown desktop status"));
+ } catch (err) {
+ reject(err instanceof Error ? err : new RelayConnectionError("Relay status could not be read"));
+ }
+ };
+ const onError = () => {
+ cleanup();
+ reject(new RelayConnectionError("Relay socket failed"));
+ };
+ const onClose = () => {
+ cleanup();
+ reject(new RelayConnectionError("Relay socket closed before desktop status"));
+ };
+ socket.addEventListener("message", onMessage);
+ socket.addEventListener("error", onError);
+ socket.addEventListener("close", onClose);
+ });
+}
+
+export function waitForBinaryMessage(socket: WebSocket): Promise {
+ return new Promise((resolve, reject) => {
+ const cleanup = () => {
+ socket.removeEventListener("message", onMessage);
+ socket.removeEventListener("error", onError);
+ socket.removeEventListener("close", onClose);
+ };
+ const onMessage = async (event: MessageEvent) => {
+ cleanup();
+ try {
+ resolve(await bytesFromWebSocketData(event.data));
+ } catch (err) {
+ reject(err);
+ }
+ };
+ const onError = () => {
+ cleanup();
+ reject(new RelayConnectionError("Relay socket failed"));
+ };
+ const onClose = () => {
+ cleanup();
+ reject(new DesktopOfflineError("Desktop connection closed"));
+ };
+ socket.addEventListener("message", onMessage);
+ socket.addEventListener("error", onError);
+ socket.addEventListener("close", onClose);
+ });
+}
+
+export function sendJSON(socket: WebSocket, value: unknown) {
+ socket.send(JSON.stringify(value));
+}
+
+export function sendBinary(socket: WebSocket, value: Uint8Array) {
+ socket.send(arrayBufferFromBytes(value));
+}
diff --git a/packages/mobile-pwa/src/remote/rpc.ts b/packages/mobile-pwa/src/remote/rpc.ts
new file mode 100644
index 0000000..c608c50
--- /dev/null
+++ b/packages/mobile-pwa/src/remote/rpc.ts
@@ -0,0 +1,97 @@
+import { utf8 } from "./bytes";
+import { EncryptedFrameTransport } from "./frameTransport";
+
+export interface RpcErrorPayload {
+ code?: number;
+ message?: string;
+}
+
+export class RpcError extends Error {
+ code?: number;
+
+ constructor(payload: RpcErrorPayload) {
+ super(payload.message || "RPC error");
+ this.name = "RpcError";
+ this.code = payload.code;
+ }
+}
+
+type Pending = {
+ resolve: (value: unknown) => void;
+ reject: (err: Error) => void;
+};
+
+export type RpcListener = (params: unknown) => void;
+
+export class JsonRpcPeer {
+ private nextId = 1;
+ private pending = new Map();
+ private listeners = new Map>();
+ private closed = false;
+
+ constructor(private readonly transport: EncryptedFrameTransport) {}
+
+ call(method: string, params: unknown = {}): Promise {
+ if (this.closed) return Promise.reject(new Error("RPC connection is closed"));
+ const id = `mobile_${this.nextId++}`;
+ const payload = { jsonrpc: "2.0", id, method, params };
+ const promise = new Promise((resolve, reject) => {
+ this.pending.set(id, {
+ resolve: value => resolve(value as T),
+ reject
+ });
+ });
+ this.transport.send(utf8(JSON.stringify(payload)));
+ return promise;
+ }
+
+ on(method: string, listener: RpcListener): () => void {
+ if (!this.listeners.has(method)) this.listeners.set(method, new Set());
+ this.listeners.get(method)?.add(listener);
+ return () => this.listeners.get(method)?.delete(listener);
+ }
+
+ async handleFrame(data: unknown) {
+ const bytes = await this.transport.decrypt(data);
+ const text = new TextDecoder().decode(bytes);
+ const message = JSON.parse(text) as {
+ id?: string;
+ method?: string;
+ params?: unknown;
+ result?: unknown;
+ error?: RpcErrorPayload;
+ };
+
+ if (Object.prototype.hasOwnProperty.call(message, "id")) {
+ const pending = this.pending.get(String(message.id));
+ if (!pending) return;
+ this.pending.delete(String(message.id));
+ if (message.error) pending.reject(new RpcError(message.error));
+ else pending.resolve(message.result);
+ return;
+ }
+
+ if (message.method) {
+ for (const listener of this.listeners.get(message.method) || []) {
+ listener(message.params);
+ }
+ }
+ }
+
+ close(err = new Error("RPC connection closed")) {
+ if (this.closed) return;
+ this.closed = true;
+ this.transport.close();
+ for (const pending of this.pending.values()) pending.reject(err);
+ this.pending.clear();
+ this.listeners.clear();
+ }
+}
+
+export function attachRpcSocket(peer: JsonRpcPeer, socket: WebSocket, onClose: (err: Error) => void) {
+ socket.addEventListener("message", event => {
+ peer.handleFrame(event.data).catch(onClose);
+ });
+ socket.addEventListener("close", () => onClose(new Error("RPC socket closed")));
+ socket.addEventListener("error", () => onClose(new Error("RPC socket failed")));
+}
diff --git a/packages/mobile-pwa/src/storage/pairingStore.ts b/packages/mobile-pwa/src/storage/pairingStore.ts
new file mode 100644
index 0000000..65f257a
--- /dev/null
+++ b/packages/mobile-pwa/src/storage/pairingStore.ts
@@ -0,0 +1,85 @@
+import { PairedProfile } from "@/remote/client";
+
+const DB_NAME = "crew44-mobile-pwa";
+const DB_VERSION = 1;
+const STORE_NAME = "pairing";
+const PROFILE_KEY = "profile";
+const PRIVATE_KEY = "devicePrivateKey";
+
+function openDb(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error || new Error("Could not open pairing storage"));
+ });
+}
+
+async function readValue(key: string): Promise {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readonly");
+ const request = tx.objectStore(STORE_NAME).get(key);
+ request.onsuccess = () => resolve((request.result as T | undefined) ?? null);
+ request.onerror = () => reject(request.error || new Error("Could not read pairing storage"));
+ tx.oncomplete = () => db.close();
+ tx.onerror = () => db.close();
+ });
+}
+
+async function writeValues(values: Array<[string, unknown]>): Promise {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readwrite");
+ const store = tx.objectStore(STORE_NAME);
+ for (const [key, value] of values) store.put(value, key);
+ tx.oncomplete = () => {
+ db.close();
+ resolve();
+ };
+ tx.onerror = () => {
+ db.close();
+ reject(tx.error || new Error("Could not write pairing storage"));
+ };
+ });
+}
+
+async function deleteValues(keys: string[]): Promise {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readwrite");
+ const store = tx.objectStore(STORE_NAME);
+ for (const key of keys) store.delete(key);
+ tx.oncomplete = () => {
+ db.close();
+ resolve();
+ };
+ tx.onerror = () => {
+ db.close();
+ reject(tx.error || new Error("Could not clear pairing storage"));
+ };
+ });
+}
+
+export async function loadPairing(): Promise<{ profile: PairedProfile; privateKey: string } | null> {
+ const [profile, privateKey] = await Promise.all([
+ readValue(PROFILE_KEY),
+ readValue(PRIVATE_KEY)
+ ]);
+ if (!profile || !privateKey) return null;
+ return { profile, privateKey };
+}
+
+export async function savePairing(profile: PairedProfile, privateKey: string): Promise {
+ await writeValues([
+ [PROFILE_KEY, profile],
+ [PRIVATE_KEY, privateKey]
+ ]);
+}
+
+export async function clearPairing(): Promise {
+ await deleteValues([PROFILE_KEY, PRIVATE_KEY]);
+}
diff --git a/packages/mobile-pwa/src/styles.css b/packages/mobile-pwa/src/styles.css
new file mode 100644
index 0000000..a4f4ca6
--- /dev/null
+++ b/packages/mobile-pwa/src/styles.css
@@ -0,0 +1,1670 @@
+:root {
+ color: #1c1a17;
+ background: #faf5e8;
+ font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+ -webkit-font-smoothing: antialiased;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ background: #faf5e8;
+}
+
+button,
+textarea,
+select {
+ font: inherit;
+}
+
+.screen {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: #faf5e8;
+}
+
+.header {
+ min-height: calc(58px + env(safe-area-inset-top));
+ padding: calc(12px + env(safe-area-inset-top)) 18px 12px;
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ border-bottom: 1px solid #e6dfcc;
+ background: #faf5e8;
+ flex-shrink: 0;
+ z-index: 30;
+}
+
+.header h1 {
+ flex: 1;
+ margin: 0;
+ color: #1c1a17;
+ font-size: 22px;
+ font-weight: 700;
+ line-height: 1.25;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.header-side {
+ display: flex;
+ align-items: center;
+}
+
+.header-right {
+ justify-content: flex-end;
+}
+
+.button,
+.icon-button {
+ border: 1px solid #1c1a17;
+ background: #1c1a17;
+ color: #fcfbf7;
+ border-radius: 7px;
+ cursor: pointer;
+ font-weight: 650;
+}
+
+.button {
+ min-height: 38px;
+ padding: 8px 14px;
+ font-size: 14px;
+}
+
+.button:disabled,
+.icon-button:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.button-ghost {
+ background: #fffef8;
+ border-color: #e6dfcc;
+ color: #5c544b;
+}
+
+.button-danger {
+ background: #fffef8;
+ border-color: #e7b8aa;
+ color: #b8553e;
+}
+
+.icon-button {
+ width: 38px;
+ height: 38px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border-color: transparent;
+ color: #1c1a17;
+}
+
+.icon-button svg,
+.camera-icon svg {
+ width: 20px;
+ height: 20px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.icon-button svg circle {
+ fill: currentColor;
+ stroke: none;
+}
+
+.back-symbol {
+ color: #1c1a17;
+ font-size: 32px;
+ line-height: 34px;
+ font-weight: 500;
+}
+
+.button-row {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+ flex-wrap: wrap;
+ margin-top: 18px;
+}
+
+.loading,
+.empty-state {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ padding: 28px;
+ text-align: center;
+ color: #807972;
+}
+
+.connecting-state {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 28px;
+ gap: 12px;
+}
+
+.connecting-state .loading {
+ flex: 0;
+}
+
+.offline-state {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 28px;
+ gap: 12px;
+ text-align: center;
+}
+
+.offline-state h2 {
+ margin: 0;
+ color: #1c1a17;
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.offline-state p {
+ margin: 0;
+ color: #807972;
+ font-size: 14px;
+ line-height: 20px;
+ max-width: 280px;
+}
+
+.offline-actions {
+ width: 100%;
+ max-width: 280px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 6px;
+}
+
+.other-options {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.other-button {
+ min-height: 38px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: #807972;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.offline-art {
+ width: 190px;
+ height: 150px;
+ position: relative;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ margin-bottom: 6px;
+}
+
+.offline-monitor {
+ width: 132px;
+ height: 88px;
+ border-radius: 12px;
+ border: 3px solid #1c1a17;
+ background: #fffef8;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.offline-face {
+ width: 74px;
+ height: 46px;
+ border-radius: 8px;
+ background: #f0ead8;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.offline-eyes {
+ display: flex;
+ gap: 20px;
+}
+
+.offline-eyes span {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #807972;
+}
+
+.offline-sleep {
+ width: 28px;
+ height: 4px;
+ border-radius: 4px;
+ background: #807972;
+}
+
+.offline-stand {
+ width: 18px;
+ height: 24px;
+ background: #1c1a17;
+}
+
+.offline-base {
+ width: 76px;
+ height: 10px;
+ border-radius: 8px;
+ background: #1c1a17;
+}
+
+.offline-cord {
+ position: absolute;
+ right: 18px;
+ bottom: 18px;
+ width: 36px;
+ height: 22px;
+ border-right: 3px solid #807972;
+ border-bottom: 3px solid #807972;
+ border-bottom-right-radius: 10px;
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ gap: 3px;
+ padding-right: 1px;
+}
+
+.offline-cord span {
+ width: 3px;
+ height: 9px;
+ background: #807972;
+ border-radius: 2px;
+ margin-bottom: -7px;
+}
+
+.loading::before {
+ content: "";
+ width: 20px;
+ height: 20px;
+ border: 2px solid #d8cfb8;
+ border-top-color: #20744a;
+ border-radius: 50%;
+ margin-bottom: 10px;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.empty-state h2 {
+ color: #1c1a17;
+ font-size: 18px;
+ margin: 0 0 8px;
+}
+
+.empty-state p {
+ margin: 0;
+ max-width: 330px;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.empty-state-actions {
+ margin-top: 18px;
+}
+
+.muted {
+ color: #807972;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.error-text,
+.inline-error {
+ color: #b8553e;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.pair-body {
+ flex: 1;
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ overflow: auto;
+}
+
+.camera-box {
+ height: 300px;
+ min-height: 300px;
+ border: 1px solid #d8cfb8;
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+ background: #efe9db;
+}
+
+.camera-box video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.camera-overlay {
+ position: absolute;
+ inset: 0;
+ border: 0;
+ background: #f0ead8;
+ color: #5c544b;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ font-weight: 700;
+}
+
+.camera-overlay small {
+ color: #807972;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+textarea {
+ width: 100%;
+ border: 1px solid #e6dfcc;
+ background: #fffef8;
+ color: #1c1a17;
+ border-radius: 8px;
+ font-size: 16px;
+ padding: 11px 12px;
+ resize: vertical;
+ min-height: 92px;
+ outline: none;
+}
+
+.link-danger-button {
+ border: 0;
+ background: transparent;
+ color: #b8553e;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.4;
+ padding: 8px;
+ cursor: pointer;
+}
+
+.pair-link-button {
+ align-self: center;
+}
+
+select {
+ border: 1px solid #e6dfcc;
+ background: #fffef8;
+ color: #1c1a17;
+ border-radius: 7px;
+ padding: 7px 9px;
+ outline: none;
+ min-width: 0;
+}
+
+.status-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 15px 18px 8px;
+ font-size: 15px;
+ font-weight: 650;
+}
+
+.online-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: #3fa45b;
+}
+
+.section-title {
+ margin: 0;
+ padding: 10px 18px 7px;
+ font-size: 18px;
+}
+
+.list {
+ flex: 1;
+ overflow: auto;
+ padding-bottom: env(safe-area-inset-bottom);
+}
+
+.row {
+ width: 100%;
+ min-height: 68px;
+ border: 0;
+ border-bottom: 1px solid #ece6d5;
+ background: transparent;
+ color: #1c1a17;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ text-align: left;
+ padding: 13px 18px;
+ cursor: pointer;
+}
+
+.row-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.row-title,
+.row-subtitle {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.row-title {
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.row-subtitle {
+ color: #807972;
+ font-size: 13px;
+ line-height: 18px;
+ margin-top: 3px;
+}
+
+.chevron,
+.row-right {
+ color: #a89f92;
+ flex-shrink: 0;
+ font-size: 26px;
+ line-height: 28px;
+}
+
+.menu-host {
+ position: relative;
+}
+
+.menu-dismiss-layer {
+ position: fixed;
+ inset: 0;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ z-index: 10;
+ cursor: default;
+}
+
+.menu {
+ position: absolute;
+ top: 42px;
+ right: 0;
+ min-width: 150px;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #fcfbf7;
+ box-shadow: 0 12px 32px rgba(28, 26, 23, 0.16);
+ z-index: 20;
+}
+
+.menu button {
+ width: 100%;
+ min-height: 42px;
+ border: 0;
+ border-bottom: 1px solid #ece6d5;
+ background: transparent;
+ color: #1c1a17;
+ text-align: left;
+ padding: 0 13px;
+ font-weight: 650;
+}
+
+.menu button:last-child {
+ border-bottom: 0;
+}
+
+.menu .danger {
+ color: #b8553e;
+}
+
+.list-footer {
+ display: flex;
+ justify-content: center;
+ padding: 16px;
+ color: #807972;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.timeline-shell {
+ flex: 1;
+ overflow: auto;
+ padding: 14px 14px 10px;
+}
+
+.timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: #c4644a;
+ color: #fcfbf7;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: 700;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.avatar-human {
+ background: #5c544b;
+}
+
+.user-message-wrap {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+}
+
+.agent-message-wrap {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+.avatar-spacer {
+ width: 28px;
+ flex-shrink: 0;
+}
+
+.agent-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.agent-header {
+ color: #1c1a17;
+ font-size: 13px;
+ margin-bottom: 4px;
+}
+
+.agent-header time {
+ color: #807972;
+ font-weight: 600;
+}
+
+.bubble {
+ border-radius: 8px;
+ border: 1px solid #e6dfcc;
+ padding: 12px;
+ max-width: 92%;
+}
+
+.agent-bubble {
+ flex: 1;
+ max-width: none;
+ background: #fffef8;
+ border-color: #e6dfcc;
+}
+
+.user-bubble {
+ background: #edf4ec;
+ border-color: #d1e5cf;
+}
+
+.message-meta {
+ color: #807972;
+ font-size: 11px;
+ line-height: 15px;
+ margin-bottom: 5px;
+ font-weight: 600;
+}
+
+.sending-text {
+ display: inline-flex;
+ margin-top: 8px;
+ color: #807972;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.rich-text,
+.rich-text-p {
+ color: #1c1a17;
+ font-size: 15px;
+ line-height: 21px;
+}
+
+.rich-text-p {
+ margin: 0 0 8px;
+}
+
+.rich-text-compact {
+ margin-bottom: 0;
+}
+
+.rich-text strong {
+ font-weight: 700;
+}
+
+.rich-text em {
+ font-style: italic;
+}
+
+.rich-text code,
+.rich-text-p code {
+ font-family: "Courier", ui-monospace, monospace;
+ font-size: 13px;
+ color: #1c1a17;
+ background: #ece6d5;
+ border-radius: 4px;
+ padding: 1px 5px;
+}
+
+.rich-ref {
+ color: #c4644a;
+ font-weight: 700;
+}
+
+.rich-text a,
+.rich-text-p a {
+ color: #2f79d8;
+ text-decoration: underline;
+}
+
+.rich-text h3,
+.rich-text h4 {
+ color: #1c1a17;
+ font-weight: 700;
+ margin: 0 0 8px;
+}
+
+.rich-text h3 {
+ font-size: 18px;
+ line-height: 24px;
+}
+
+.rich-text h4 {
+ font-size: 16px;
+ line-height: 22px;
+}
+
+.rich-text hr {
+ height: 1px;
+ border: 0;
+ background: #e6dfcc;
+ margin: 10px 0;
+}
+
+.rich-code {
+ color: #1c1a17;
+ font-family: "Courier", ui-monospace, monospace;
+ font-size: 13px;
+ line-height: 18px;
+ background: #f4efe0;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ padding: 10px;
+ margin: 0 0 8px;
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.rich-table-wrap {
+ overflow-x: auto;
+ margin: 8px 0;
+ border-radius: 8px;
+ border: 1px solid #e6dfcc;
+}
+
+.rich-table {
+ width: 100%;
+ min-width: 320px;
+ border-collapse: collapse;
+ color: #1c1a17;
+ font-size: 13px;
+ line-height: 19px;
+}
+
+.rich-table th,
+.rich-table td {
+ border-right: 1px solid #e6dfcc;
+ border-bottom: 1px solid #e6dfcc;
+ padding: 6px 8px;
+ vertical-align: top;
+}
+
+.rich-table th:last-child,
+.rich-table td:last-child {
+ border-right: 0;
+}
+
+.rich-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.rich-table th {
+ background: #f7efdd;
+ font-weight: 700;
+}
+
+.rich-table td {
+ background: #fffef8;
+}
+
+.rich-table tbody tr:nth-child(even) td {
+ background: #faf5e8;
+}
+
+.cw-math-inline {
+ display: inline-block;
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ vertical-align: -0.12em;
+}
+
+.cw-math-block {
+ display: block;
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ margin: 8px 0;
+ padding-bottom: 2px;
+}
+
+.rich-list {
+ margin: 0 0 8px;
+ padding-left: 24px;
+ color: #1c1a17;
+ font-size: 15px;
+ line-height: 21px;
+}
+
+.rich-list li {
+ padding-left: 3px;
+}
+
+.attachment-tray {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.attachment-chip {
+ width: 100%;
+ max-width: 260px;
+ min-height: 52px;
+ border-radius: 8px;
+ border: 1px solid #e6dfcc;
+ background: #f0ead8;
+ padding: 8px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+
+.attachment-thumb-image,
+.attachment-glyph {
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ background: #f1ebdc;
+ flex-shrink: 0;
+}
+
+.attachment-thumb-image {
+ object-fit: cover;
+}
+
+.attachment-glyph {
+ display: inline-block;
+ position: relative;
+ border: 1.4px solid #807972;
+}
+
+.attachment-folder::before {
+ content: "";
+ position: absolute;
+ top: 7px;
+ left: 3px;
+ width: 14px;
+ height: 8px;
+ border-radius: 4px 4px 0 0;
+ border: 1.4px solid #807972;
+ border-bottom: 0;
+ background: #e7dcc0;
+}
+
+.attachment-folder::after {
+ content: "";
+ position: absolute;
+ left: 2px;
+ right: 2px;
+ bottom: 5px;
+ height: 16px;
+ border-radius: 5px;
+ border: 1.4px solid #807972;
+ background: #f1ebdc;
+}
+
+.attachment-file::before,
+.attachment-image::before {
+ content: "";
+ position: absolute;
+ left: 7px;
+ right: 7px;
+ top: 18px;
+ height: 1.4px;
+ background: #807972;
+ box-shadow: 0 5px 0 #807972;
+}
+
+.attachment-failed {
+ background: #f9e8e2;
+ border-color: #b8553e;
+}
+
+.attachment-failed::before {
+ background: #b8553e;
+ box-shadow: 0 5px 0 #b8553e;
+}
+
+.attachment-meta {
+ min-width: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.attachment-name,
+.attachment-kind {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.attachment-name {
+ color: #1c1a17;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.attachment-kind {
+ color: #807972;
+ font-size: 10px;
+ font-weight: 700;
+ margin-top: 2px;
+}
+
+.event-box {
+ background: #f7efdd;
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ padding: 11px;
+}
+
+.event-text {
+ margin: 0;
+ color: #1c1a17;
+ font-size: 13px;
+ line-height: 19px;
+ overflow-wrap: anywhere;
+}
+
+.error-box {
+ border-color: #e7b8aa;
+ background: #fff7f4;
+ padding: 0;
+ overflow: hidden;
+}
+
+.error-header-band {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 11px 9px;
+ background: #f7ded7;
+ border-bottom: 1px solid #efc2b5;
+}
+
+.error-header-icon {
+ color: #9f4330;
+ display: inline-flex;
+ flex: 0 0 auto;
+}
+
+.error-header-title {
+ color: #b23a2e;
+ font-size: 12.5px;
+ line-height: 1.2;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+ overflow-wrap: anywhere;
+}
+
+.error-code-chip {
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11.5px;
+ color: #b23a2e;
+ background: #f1d4c9;
+ padding: 1px 6px;
+ border-radius: 4px;
+ overflow-wrap: anywhere;
+}
+
+.error-header-spacer {
+ flex: 1;
+ min-width: 0;
+}
+
+.error-header-time {
+ font-size: 11.5px;
+ color: #9c5142;
+ flex: 0 0 auto;
+}
+
+.error-body {
+ padding: 10px 14px;
+}
+
+.error-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 8px;
+ margin: 0;
+}
+
+.error-meta-row-context {
+ margin: 8px 0 0;
+}
+
+.error-meta-chip {
+ color: #b8553e;
+ font-size: 12px;
+ line-height: 18px;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.error-meta-chip::after {
+ content: "·";
+ margin-left: 8px;
+ color: #d08c78;
+}
+
+.error-meta-row .error-meta-chip:last-child::after {
+ content: none;
+ margin-left: 0;
+}
+
+.error-meta-chip-context {
+ color: #9c5d4d;
+}
+
+.thought-wrap {
+ margin-bottom: 8px;
+}
+
+.thought-chip {
+ min-height: 28px;
+ border: 1px solid #e6dfcc;
+ border-radius: 999px;
+ background: #f7efdd;
+ color: #807972;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 10px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.thought-text {
+ margin: 7px 0 0;
+ color: #1c1a17;
+ font-size: 13px;
+ line-height: 19px;
+ white-space: pre-wrap;
+}
+
+.handover {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 0;
+}
+
+.handover-line {
+ flex: 1;
+ height: 1px;
+ background: #e6dfcc;
+}
+
+.handover-chip {
+ max-width: 82%;
+ border: 1px solid #e6dfcc;
+ border-radius: 999px;
+ background: #fffef8;
+ padding: 5px 8px;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.handover-text {
+ min-width: 0;
+ color: #807972;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.handover-text strong,
+.strong-text {
+ color: #1c1a17;
+ font-weight: 700;
+}
+
+.muted-text {
+ color: #807972;
+}
+
+.tool-line,
+.tool-group {
+ border: 1px solid transparent;
+ background: transparent;
+ border-radius: 6px;
+ overflow: hidden;
+ margin: 2px 0;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.tool-line-open,
+.tool-group.tool-line-open {
+ border-color: #ece6d5;
+ background: #fcfaf1;
+ margin-left: 10px;
+}
+
+.tool-group-title {
+ color: #1c1a17;
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.tool-summary {
+ width: 100%;
+ min-height: 30px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #5c544b;
+ padding: 4px 8px;
+ text-align: left;
+}
+
+.tool-summary-open {
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr) auto;
+ align-items: center;
+ column-gap: 8px;
+ row-gap: 3px;
+}
+
+.tool-summary-clickable {
+ cursor: pointer;
+}
+
+.tool-summary-clickable:focus-visible {
+ outline: 2px solid #d9a999;
+ outline-offset: -2px;
+ border-radius: 6px;
+}
+
+.tool-toggle {
+ width: 22px;
+ height: 22px;
+ flex: 0 0 22px;
+ color: #a89f92;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.tool-caret {
+ color: currentColor;
+ display: inline-flex;
+ transform: rotate(0deg);
+ transition: transform 0.15s;
+ font-size: 14px;
+ line-height: 1;
+}
+
+.tool-caret-open {
+ transform: rotate(90deg);
+}
+
+.tool-caret-muted {
+ opacity: 0.3;
+}
+
+.tool-summary strong:not(.tool-group-title) {
+ color: #1c1a17;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.tool-summary-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.tool-summary-title-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.tool-summary-open .tool-summary-main {
+ grid-column: 2;
+ grid-row: 1;
+ align-self: center;
+}
+
+.tool-summary-open .tool-toggle {
+ grid-column: 1;
+ grid-row: 1;
+ align-self: center;
+}
+
+.tool-summary-open .tool-status,
+.tool-summary-open .tool-status-wrap,
+.tool-summary-open .tool-dot {
+ grid-column: 3;
+ grid-row: 1;
+ align-self: center;
+}
+
+.tool-path {
+ flex: 1;
+ min-width: 0;
+ color: #a89f92;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tool-path-open {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ align-self: start;
+ color: #807972;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.tool-flex {
+ flex: 1;
+}
+
+.tool-status {
+ color: #a89f92;
+ font-size: 11px;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.tool-status-error {
+ color: #b8553e;
+}
+
+.tool-status-wrap {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex-shrink: 0;
+}
+
+.tool-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #6e9e5b;
+ flex-shrink: 0;
+}
+
+.tool-dot-error {
+ background: #b8553e;
+}
+
+.tool-detail-wrap,
+.tool-group-details {
+ border-top: 1px solid #e6dfcc;
+ background: #fffef8;
+}
+
+.tool-detail-wrap {
+ padding: 10px 14px;
+}
+
+.tool-group-details {
+ padding: 6px 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.tool-group-details .tool-line {
+ margin-left: 0;
+}
+
+.tool-loading,
+.tool-error,
+.tool-path-expanded {
+ margin: 0 0 8px;
+ font-size: 12px;
+}
+
+.tool-error {
+ color: #b8553e;
+}
+
+.tool-output {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.tool-output-section {
+ margin-top: 0;
+}
+
+.tool-output-label {
+ margin-bottom: 4px;
+ color: #807972;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+ text-transform: uppercase;
+}
+
+.tool-output-label-error {
+ color: #b23a2e;
+}
+
+.tool-pre-text {
+ margin: 0;
+ max-height: 260px;
+ overflow: auto;
+ overscroll-behavior: none;
+ padding: 9px 10px;
+ color: #1c1a17;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12.5px;
+ line-height: 1.55;
+}
+
+.tool-pre-text-singleline {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.tool-pre-text-multiline {
+ white-space: pre;
+ overflow-wrap: normal;
+ word-break: normal;
+}
+
+.tool-pre-text-error {
+ color: #b23a2e;
+}
+
+.streaming-label {
+ margin: 0;
+ padding: 0 14px 8px;
+ color: #807972;
+ font-size: 12px;
+ font-weight: 650;
+}
+
+.mention-menu {
+ margin: 0 12px 8px;
+ border-radius: 8px;
+ border: 1px solid #e6dfcc;
+ background: #fffef8;
+ overflow: hidden;
+}
+
+.mention-item {
+ width: 100%;
+ min-height: 46px;
+ border: 0;
+ border-bottom: 1px solid #e6dfcc;
+ background: transparent;
+ color: #1c1a17;
+ padding: 0 12px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ text-align: left;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.mention-item:last-child {
+ border-bottom: 0;
+}
+
+.mention-avatar {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background: #1c1a17;
+ color: #fcfbf7;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.composer {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 12px calc(12px + env(safe-area-inset-bottom));
+ border-top: 1px solid #e6dfcc;
+ background: #faf5e8;
+}
+
+.composer textarea {
+ width: 100%;
+ min-height: 22px;
+ max-height: 66px;
+ resize: none;
+ font-size: 16px;
+ line-height: 22px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+
+.composer textarea:focus {
+ outline: none;
+}
+
+.agent-picker {
+ position: relative;
+ align-self: flex-start;
+ z-index: 20;
+}
+
+.composer-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 28px;
+}
+
+.composer-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.agent-picker-chip {
+ min-height: 30px;
+ max-width: 190px;
+ border-radius: 999px;
+ border: 1px solid #e6dfcc;
+ background: #fffef8;
+ color: #1c1a17;
+ padding: 4px 10px 4px 4px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.agent-picker-chip span:not(.agent-picker-avatar):not(.agent-picker-caret) {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.agent-picker-avatar {
+ border-radius: 50%;
+ background: #a9a256;
+ color: #fcfbf7;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 800;
+ flex-shrink: 0;
+}
+
+.agent-picker-caret {
+ color: #a89f92;
+ margin-left: 1px;
+ transform: rotate(90deg);
+ transition: transform 0.15s;
+}
+
+.agent-picker-caret-open {
+ transform: rotate(-90deg);
+}
+
+.agent-picker-menu {
+ position: absolute;
+ left: 0;
+ bottom: 36px;
+ width: 230px;
+ border-radius: 8px;
+ border: 1px solid #e6dfcc;
+ background: #fffef8;
+ padding: 5px;
+ box-shadow: 0 8px 18px rgba(28, 26, 23, 0.12);
+ z-index: 30;
+}
+
+.agent-picker-label {
+ color: #807972;
+ font-size: 10px;
+ font-weight: 800;
+ text-transform: uppercase;
+ padding: 5px 8px 3px;
+}
+
+.agent-picker-item {
+ width: 100%;
+ min-height: 38px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: #1c1a17;
+ padding: 7px 8px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.agent-picker-item-active {
+ background: #f7efdd;
+}
+
+.agent-picker-item-name {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.agent-picker-check {
+ color: #b8553e;
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.send-button,
+.stop-button {
+ min-width: 30px;
+ min-height: 30px;
+ border-radius: 999px;
+ padding: 0;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.send-button {
+ border: 0;
+ background: #1c1a17;
+ color: #fcfbf7;
+}
+
+.send-button:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
+
+.stop-button {
+ border: 1px solid #e6dfcc;
+ background: #fffef8;
+ color: #807972;
+}
+
+.send-button svg,
+.stop-button svg {
+ width: 15px;
+ height: 15px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.6;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.send-button svg {
+ margin-left: 1px;
+}
+
+.stop-button svg rect {
+ fill: currentColor;
+ stroke: none;
+}
+
+.agent-detail {
+ padding: 18px;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.detail-card {
+ border: 1px solid #e6dfcc;
+ border-radius: 8px;
+ background: #fffef8;
+ padding: 14px;
+}
+
+.detail-card span {
+ display: block;
+ color: #807972;
+ font-size: 12px;
+ font-weight: 700;
+ margin-bottom: 7px;
+ text-transform: uppercase;
+}
+
+.detail-card strong {
+ color: #1c1a17;
+ font-size: 15px;
+ font-weight: 600;
+ word-break: break-word;
+}
+
+.detail-card p {
+ margin: 0;
+ color: #1c1a17;
+ font-size: 14px;
+ line-height: 21px;
+ white-space: pre-wrap;
+}
diff --git a/packages/mobile-pwa/src/ui/AgentTargetPicker.tsx b/packages/mobile-pwa/src/ui/AgentTargetPicker.tsx
new file mode 100644
index 0000000..0f8725b
--- /dev/null
+++ b/packages/mobile-pwa/src/ui/AgentTargetPicker.tsx
@@ -0,0 +1,85 @@
+import React from "react";
+import { Agent } from "@/api/types";
+
+function initialFor(agent?: Agent): string {
+ return (agent?.name || "?")[0].toUpperCase();
+}
+
+function AgentAvatar({ agent, size = 20 }: { agent?: Agent; size?: number }) {
+ return (
+
+ {initialFor(agent)}
+
+ );
+}
+
+export function AgentTargetPicker({
+ agents,
+ value,
+ onChange,
+ disabled = false
+}: {
+ agents: Agent[];
+ value: string;
+ onChange: (id: string) => void;
+ disabled?: boolean;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const ref = React.useRef(null);
+ const selected = agents.find(agent => agent.id === value) || agents[0];
+
+ React.useEffect(() => {
+ if (!open) return;
+ const close = (event: MouseEvent) => {
+ if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", close);
+ return () => document.removeEventListener("mousedown", close);
+ }, [open]);
+
+ if (!selected) return null;
+
+ return (
+
+ {open ? (
+
+
Direct to
+ {agents.map(agent => {
+ const active = agent.id === selected.id;
+ return (
+
{
+ onChange(agent.id);
+ setOpen(false);
+ }}
+ >
+
+ {agent.name}
+ {active ? ✓ : null}
+
+ );
+ })}
+
+ ) : null}
+
setOpen(value => !value)}
+ >
+
+ {selected.name}
+ ›
+
+
+ );
+}
diff --git a/packages/mobile-pwa/src/ui/AttachmentTray.tsx b/packages/mobile-pwa/src/ui/AttachmentTray.tsx
new file mode 100644
index 0000000..40838ff
--- /dev/null
+++ b/packages/mobile-pwa/src/ui/AttachmentTray.tsx
@@ -0,0 +1,39 @@
+import { MessageAttachment } from "@/api/types";
+
+function extensionForName(name: string): string {
+ const match = name.match(/\.([^.]+)$/);
+ return match ? match[1].toUpperCase() : "";
+}
+
+function displayName(attachment: MessageAttachment): string {
+ if (attachment.display_name) return attachment.display_name;
+ const parts = attachment.path.split(/[\\/]/).filter(Boolean);
+ return parts[parts.length - 1] || attachment.path || "Attachment";
+}
+
+function AttachmentThumb({ attachment }: { attachment: MessageAttachment }) {
+ if (attachment.kind === "image" && attachment.thumbnail_jpeg_base64) {
+ return
;
+ }
+ return ;
+}
+
+export function AttachmentTray({ attachments }: { attachments?: MessageAttachment[] }) {
+ if (!attachments?.length) return null;
+ return (
+
+ {attachments.map(attachment => {
+ const name = displayName(attachment);
+ return (
+
+
+
+ {name}
+ {extensionForName(name) || attachment.kind}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/mobile-pwa/src/ui/RichText.tsx b/packages/mobile-pwa/src/ui/RichText.tsx
new file mode 100644
index 0000000..9c3bf53
--- /dev/null
+++ b/packages/mobile-pwa/src/ui/RichText.tsx
@@ -0,0 +1,409 @@
+import React from "react";
+import katex from "katex";
+import "katex/dist/katex.min.css";
+
+type Align = "left" | "center" | "right" | undefined;
+
+type InlineToken =
+ | { kind: "text"; value: string }
+ | { kind: "file"; value: string }
+ | { kind: "ref"; value: string }
+ | { kind: "bold"; value: string }
+ | { kind: "italic"; value: string }
+ | { kind: "code"; value: string }
+ | { kind: "link"; label: string; url: string }
+ | { kind: "math"; value: string; displayMode: false };
+
+type Block =
+ | { kind: "p"; lines: string[] }
+ | { kind: "h"; level: number; text: string }
+ | { kind: "hr" }
+ | { kind: "code"; lines: string[]; lang: string }
+ | { kind: "math"; value: string; displayMode: true }
+ | { kind: "table"; header: string[]; alignments: Align[]; rows: string[][] }
+ | { kind: "ul" | "ol"; items: string[] };
+
+function pushTextToken(tokens: InlineToken[], text: string) {
+ if (!text) return;
+ const last = tokens[tokens.length - 1];
+ if (last?.kind === "text") last.value += text;
+ else tokens.push({ kind: "text", value: text });
+}
+
+function tokenizeInline(text: string): InlineToken[] {
+ const tokens: InlineToken[] = [];
+ let plainStart = 0;
+ let i = 0;
+
+ const flushPlain = (end: number) => {
+ pushTextToken(tokens, text.slice(plainStart, end));
+ plainStart = end;
+ };
+
+ while (i < text.length) {
+ if (text.startsWith("{{file:", i) || text.startsWith("{{ref:", i)) {
+ const close = text.indexOf("}}", i + 2);
+ if (close !== -1) {
+ flushPlain(i);
+ const raw = text.slice(i + 2, close);
+ const sep = raw.indexOf(":");
+ const kind = raw.slice(0, sep);
+ if (kind === "file" || kind === "ref") tokens.push({ kind, value: raw.slice(sep + 1) });
+ i = close + 2;
+ plainStart = i;
+ continue;
+ }
+ }
+
+ if (text.startsWith("**", i)) {
+ const close = text.indexOf("**", i + 2);
+ if (close !== -1) {
+ flushPlain(i);
+ tokens.push({ kind: "bold", value: text.slice(i + 2, close) });
+ i = close + 2;
+ plainStart = i;
+ continue;
+ }
+ }
+
+ if (text[i] === "`") {
+ const close = text.indexOf("`", i + 1);
+ if (close !== -1) {
+ flushPlain(i);
+ tokens.push({ kind: "code", value: text.slice(i + 1, close) });
+ i = close + 1;
+ plainStart = i;
+ continue;
+ }
+ }
+
+ if (text.startsWith("\\(", i)) {
+ const close = text.indexOf("\\)", i + 2);
+ if (close !== -1) {
+ flushPlain(i);
+ tokens.push({ kind: "math", value: text.slice(i + 2, close), displayMode: false });
+ i = close + 2;
+ plainStart = i;
+ continue;
+ }
+ }
+
+ if (text[i] === "$" && text[i + 1] !== "$") {
+ const close = text.indexOf("$", i + 1);
+ const value = close === -1 ? "" : text.slice(i + 1, close);
+ if (close !== -1 && value && value.trim() === value) {
+ flushPlain(i);
+ tokens.push({ kind: "math", value, displayMode: false });
+ i = close + 1;
+ plainStart = i;
+ continue;
+ }
+ }
+
+ const link = text[i] === "[" ? text.slice(i).match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/) : null;
+ if (link) {
+ flushPlain(i);
+ tokens.push({ kind: "link", label: link[1], url: link[2] });
+ i += link[0].length;
+ plainStart = i;
+ continue;
+ }
+
+ if (text[i] === "*") {
+ const close = text.indexOf("*", i + 1);
+ const value = close === -1 ? "" : text.slice(i + 1, close);
+ if (close !== -1 && value && !value.includes("\n")) {
+ flushPlain(i);
+ tokens.push({ kind: "italic", value });
+ i = close + 1;
+ plainStart = i;
+ continue;
+ }
+ }
+
+ i += 1;
+ }
+
+ pushTextToken(tokens, text.slice(plainStart));
+ return tokens;
+}
+
+function MathNode({ value, displayMode = false }: { value: string; displayMode?: boolean }) {
+ const html = katex.renderToString(value, {
+ displayMode,
+ throwOnError: false,
+ strict: "ignore",
+ trust: false
+ });
+ const Tag = displayMode ? "div" : "span";
+ return (
+
+ );
+}
+
+function InlineText({ text }: { text: string }) {
+ return (
+ <>
+ {tokenizeInline(text).map((token, index) => {
+ if (token.kind === "bold") return {token.value};
+ if (token.kind === "italic") return {token.value};
+ if (token.kind === "code" || token.kind === "file") return {token.value};
+ if (token.kind === "ref") return @{token.value};
+ if (token.kind === "math") return ;
+ if (token.kind === "link") return {token.label};
+ return {token.value};
+ })}
+ >
+ );
+}
+
+function splitTableRow(line: string): string[] | null {
+ let value = line.trim();
+ if (!value.includes("|")) return null;
+ if (value.startsWith("|")) value = value.slice(1);
+ if (value.endsWith("|")) value = value.slice(0, -1);
+
+ const cells: string[] = [];
+ let cell = "";
+ let inCode = false;
+ for (let index = 0; index < value.length; index += 1) {
+ const char = value[index];
+ if (char === "`") inCode = !inCode;
+ if (char === "\\" && !inCode) {
+ const next = value[index + 1];
+ if (next === "|" || next === "\\") {
+ cell += next;
+ index += 1;
+ continue;
+ }
+ cell += char;
+ continue;
+ }
+ if (char === "|" && !inCode) {
+ cells.push(cell.trim());
+ cell = "";
+ } else {
+ cell += char;
+ }
+ }
+ cells.push(cell.trim());
+ return cells;
+}
+
+function parseDelimiterRow(line: string): Align[] | null {
+ const cells = splitTableRow(line);
+ if (!cells || cells.length < 1) return null;
+ const alignments: Align[] = [];
+ for (const cell of cells) {
+ if (!/^:?-+:?$/.test(cell)) return null;
+ const left = cell.startsWith(":");
+ const right = cell.endsWith(":");
+ alignments.push(left && right ? "center" : right ? "right" : left ? "left" : undefined);
+ }
+ return alignments;
+}
+
+function parseTableAt(lines: string[], start: number): { block: Block; nextIndex: number } | null {
+ const header = splitTableRow(lines[start]);
+ if (!header || !lines[start + 1]) return null;
+ const alignments = parseDelimiterRow(lines[start + 1]);
+ if (!alignments || alignments.length !== header.length) return null;
+
+ const rows: string[][] = [];
+ let index = start + 2;
+ while (index < lines.length) {
+ const raw = lines[index].replace(/\s+$/, "");
+ if (!raw.trim() || !raw.includes("|")) break;
+ const row = splitTableRow(raw);
+ if (!row) break;
+ rows.push(header.map((_, cellIndex) => row[cellIndex] || ""));
+ index += 1;
+ }
+
+ return { block: { kind: "table", header, alignments, rows }, nextIndex: index };
+}
+
+function parseBlocks(text: string): Block[] {
+ const lines = text.split("\n");
+ const blocks: Block[] = [];
+ let para: string[] = [];
+ let list: Extract | null = null;
+ let fence: { lang: string; lines: string[] } | null = null;
+ let mathBlock: { end: "$$" | "\\]"; lines: string[] } | null = null;
+
+ const flushPara = () => {
+ if (para.length) blocks.push({ kind: "p", lines: para });
+ para = [];
+ };
+ const flushList = () => {
+ if (list?.items.length) blocks.push(list);
+ list = null;
+ };
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const raw = lines[index];
+ const fenceMatch = raw.match(/^\s*```\s*([\w+-]*)\s*$/);
+
+ if (fence) {
+ if (fenceMatch) {
+ blocks.push({ kind: "code", lang: fence.lang, lines: fence.lines });
+ fence = null;
+ } else {
+ fence.lines.push(raw);
+ }
+ continue;
+ }
+
+ if (mathBlock) {
+ if (raw.trim() === mathBlock.end) {
+ blocks.push({ kind: "math", displayMode: true, value: mathBlock.lines.join("\n") });
+ mathBlock = null;
+ } else {
+ mathBlock.lines.push(raw);
+ }
+ continue;
+ }
+
+ if (fenceMatch) {
+ flushPara();
+ flushList();
+ fence = { lang: fenceMatch[1] || "", lines: [] };
+ continue;
+ }
+
+ const line = raw.replace(/\s+$/, "");
+ const singleLineDollarMath = line.match(/^\s*\$\$\s*(\S[\s\S]*?)\s*\$\$\s*$/);
+ const singleLineBracketMath = line.match(/^\s*\\\[\s*(\S[\s\S]*?)\s*\\\]\s*$/);
+ const heading = line.match(/^\s*(#{1,4})\s+(.+)$/);
+ const bullet = /^\s*[-*]\s+/.test(line);
+ const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
+ const hr = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line);
+ const table = parseTableAt(lines, index);
+
+ if (singleLineDollarMath || singleLineBracketMath) {
+ flushPara();
+ flushList();
+ blocks.push({ kind: "math", displayMode: true, value: singleLineDollarMath?.[1] || singleLineBracketMath?.[1] || "" });
+ } else if (line.trim() === "$$" || line.trim() === "\\[") {
+ flushPara();
+ flushList();
+ mathBlock = { end: line.trim() === "$$" ? "$$" : "\\]", lines: [] };
+ } else if (table) {
+ flushPara();
+ flushList();
+ blocks.push(table.block);
+ index = table.nextIndex - 1;
+ } else if (heading) {
+ flushPara();
+ flushList();
+ blocks.push({ kind: "h", level: heading[1].length, text: heading[2] });
+ } else if (hr) {
+ flushPara();
+ flushList();
+ blocks.push({ kind: "hr" });
+ } else if (bullet) {
+ flushPara();
+ if (!list || list.kind !== "ul") {
+ flushList();
+ list = { kind: "ul", items: [] };
+ }
+ list.items.push(line.replace(/^\s*[-*]\s+/, ""));
+ } else if (numbered) {
+ flushPara();
+ if (!list || list.kind !== "ol") {
+ flushList();
+ list = { kind: "ol", items: [] };
+ }
+ list.items.push(numbered[1]);
+ } else if (line.trim() === "") {
+ flushPara();
+ flushList();
+ } else {
+ flushList();
+ para.push(line);
+ }
+ }
+ if (fence) blocks.push({ kind: "code", lang: fence.lang, lines: fence.lines });
+ if (mathBlock) blocks.push({ kind: "math", displayMode: true, value: mathBlock.lines.join("\n") });
+ flushPara();
+ flushList();
+ return blocks;
+}
+
+function Paragraph({ lines, compact }: { lines: string[]; compact?: boolean }) {
+ return (
+
+ {lines.map((line, index) => (
+
+ {index > 0 ?
: null}
+
+
+ ))}
+
+ );
+}
+
+function RichTable({ block }: { block: Extract }) {
+ return (
+
+
+
+
+ {block.header.map((cell, cellIndex) => (
+ |
+
+ |
+ ))}
+
+
+
+ {block.rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+export function RichText({ text }: { text: string }) {
+ if (!text) return null;
+ const blocks = parseBlocks(text);
+ if (blocks.length === 1 && blocks[0].kind === "p") {
+ return ;
+ }
+ return (
+
+ {blocks.map((block, index) => {
+ if (block.kind === "h") {
+ const tag = `h${Math.min(block.level + 1, 6)}`;
+ return React.createElement(tag, { key: index },
);
+ }
+ if (block.kind === "hr") return
;
+ if (block.kind === "code") return
{block.lines.join("\n")};
+ if (block.kind === "math") return
;
+ if (block.kind === "table") return
;
+ if (block.kind === "p") return
;
+ if (block.kind === "ul" || block.kind === "ol") {
+ const ListTag = block.kind;
+ return (
+
+ {block.items.map((item, itemIndex) => )}
+
+ );
+ }
+ return null;
+ })}
+
+ );
+}
diff --git a/packages/mobile-pwa/src/ui/Screen.tsx b/packages/mobile-pwa/src/ui/Screen.tsx
new file mode 100644
index 0000000..a7d6acc
--- /dev/null
+++ b/packages/mobile-pwa/src/ui/Screen.tsx
@@ -0,0 +1,173 @@
+import React from "react";
+
+export function Screen({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+export function Header({
+ title,
+ left,
+ right
+}: {
+ title: string;
+ left?: React.ReactNode;
+ right?: React.ReactNode;
+}) {
+ return (
+
+ {left ? {left}
: null}
+ {title}
+ {right ? {right}
: null}
+
+ );
+}
+
+export function Button({
+ children,
+ onClick,
+ disabled,
+ variant = "primary",
+ type = "button"
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ variant?: "primary" | "ghost" | "danger";
+ type?: "button" | "submit";
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function IconButton({
+ label,
+ onClick,
+ children,
+ disabled,
+ type = "button"
+}: {
+ label: string;
+ onClick?: () => void;
+ children: React.ReactNode;
+ disabled?: boolean;
+ type?: "button" | "submit";
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function Row({
+ title,
+ subtitle,
+ onClick,
+ right
+}: {
+ title: string;
+ subtitle?: string;
+ onClick?: () => void;
+ right?: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {subtitle ? {subtitle} : null}
+
+ {right ? {right} : ›}
+
+ );
+}
+
+export function EmptyState({ title, body, children }: { title: string; body?: string; children?: React.ReactNode }) {
+ return (
+
+ {title}
+ {body ? {body}
: null}
+ {children}
+
+ );
+}
+
+export function LoadingState({ label = "Loading..." }: { label?: string }) {
+ return {label}
;
+}
+
+function OfflineComputer() {
+ return (
+
+ );
+}
+
+export function OtherOptions({ onUnpair }: { onUnpair: () => void }) {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ setOpen(value => !value)}>
+ Other options
+
+ {open ? Unpair : null}
+
+ );
+}
+
+export function OfflineState({
+ title,
+ message,
+ onRetry,
+ onUnpair
+}: {
+ title: string;
+ message: string;
+ onRetry: () => void;
+ onUnpair: () => void;
+}) {
+ return (
+
+
+ {title}
+ {message || "The mobile app cannot reach your paired desktop right now."}
+
+ Retry
+
+
+
+ );
+}
+
+export function ConnectingState({
+ label = "Connecting to the Crew44 desktop...",
+ onUnpair,
+ showOtherOptions = false
+}: {
+ label?: string;
+ onUnpair: () => void;
+ showOtherOptions?: boolean;
+}) {
+ return (
+
+
+ {showOtherOptions ? : null}
+
+ );
+}
diff --git a/packages/mobile-pwa/src/ui/Timeline.tsx b/packages/mobile-pwa/src/ui/Timeline.tsx
new file mode 100644
index 0000000..3372567
--- /dev/null
+++ b/packages/mobile-pwa/src/ui/Timeline.tsx
@@ -0,0 +1,364 @@
+import React from "react";
+import { Agent } from "@/api/types";
+import { ErrorItem, RenderableTimelineItem, ThinkingItem, ToolItem } from "@/api/events";
+import { AttachmentTray } from "@/ui/AttachmentTray";
+import { RichText } from "@/ui/RichText";
+import { ToolOutput } from "@/ui/ToolOutput";
+
+export type LoadedToolDetails = Pick;
+
+function resolveAuthor(id: string, agents: Agent[], name?: string) {
+ if (id === "__human__") return { name: "You", initial: "Y", human: true };
+ const agent = agents.find(item => item.id === id);
+ const displayName = agent?.name || name || "Agent";
+ return { name: displayName, initial: (displayName || "?")[0].toUpperCase(), human: false };
+}
+
+function Avatar({ initial, human, size }: { initial: string; human?: boolean; size?: number }) {
+ return (
+
+ {initial}
+
+ );
+}
+
+function ThoughtChip({ thought }: { thought: ThinkingItem }) {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+
setOpen(value => !value)}>
+ {open ? "Thinking" : "Thought"}
+ ›
+
+ {open ?
{thought.reasoning}
: null}
+
+ );
+}
+
+function ErrorDetails({ item }: { item: ErrorItem }) {
+ const agentMeta = [
+ item.agent_name ? `raised by ${item.agent_name}` : "",
+ item.target_agent_name ? `target ${item.target_agent_name}` : ""
+ ].filter(Boolean);
+ return (
+
+
+
+
+
+
+ {(item.subtype || "error").replace(/_/g, " ")}
+
+ {item.code ?
{item.code} : null}
+
+
{item.time}
+
+
+
{item.message}
+ {agentMeta.length ? (
+
+ {agentMeta.map(entry => (
+ {entry}
+ ))}
+
+ ) : null}
+
+
+ );
+}
+
+function ToolStatus({ result }: { result: ToolItem["result"] }) {
+ if (result === "pending") return running;
+ if (result === "error") {
+ return (
+
+
+ failed
+
+ );
+ }
+ return ;
+}
+
+function toolGroupSummary(events: ToolItem[]): string {
+ const groups: Array<{ name: string; count: number }> = [];
+ const seen = new Map();
+ for (const event of events) {
+ const index = seen.get(event.tool);
+ if (index == null) {
+ seen.set(event.tool, groups.length);
+ groups.push({ name: event.tool, count: 1 });
+ } else {
+ groups[index].count += 1;
+ }
+ }
+ return groups.map(group => `${group.name}${group.count > 1 ? ` x${group.count}` : ""}`).join(" · ");
+}
+
+function ToolLine({
+ tool,
+ onLoadToolDetails
+}: {
+ tool: ToolItem;
+ onLoadToolDetails?: (toolCallSeq: number) => Promise;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const [loaded, setLoaded] = React.useState(null);
+ const [loadingDetails, setLoadingDetails] = React.useState(false);
+ const [detailError, setDetailError] = React.useState("");
+ const effectiveTool = loaded ? { ...tool, ...loaded, compact: false } : tool;
+ const headerPath = effectiveTool.path.trim();
+ const detail = effectiveTool.output || effectiveTool.detail || "";
+ const canOpen = Boolean(tool.compact || detail || headerPath.length > 70);
+ const openTool = async () => {
+ if (!canOpen) return;
+ const nextOpen = !open;
+ setOpen(nextOpen);
+ if (!nextOpen || !tool.compact || loaded || loadingDetails || !onLoadToolDetails) return;
+ setLoadingDetails(true);
+ setDetailError("");
+ try {
+ setLoaded(await onLoadToolDetails(tool._seq));
+ } catch (err) {
+ setDetailError(err instanceof Error ? err.message : "Failed to load tool details");
+ } finally {
+ setLoadingDetails(false);
+ }
+ };
+ const handleSummaryKeyDown = (event: React.KeyboardEvent) => {
+ if (!canOpen) return;
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ openTool().catch(() => {});
+ };
+ return (
+
+
{ openTool().catch(() => {}); } : undefined}
+ onKeyDown={handleSummaryKeyDown}
+ >
+
+ ›
+
+
+
+ {effectiveTool.tool}
+ {!open && headerPath ? {headerPath} : }
+
+
+
+ {open && headerPath ?
{headerPath}
: null}
+
+ {open ? (
+
+ {loadingDetails ?
Loading details...
: null}
+ {detailError ?
{detailError}
: null}
+ {detail ?
: null}
+
+ ) : null}
+
+ );
+}
+
+function ToolGutter({
+ author,
+ authorName,
+ time,
+ agents,
+ showHeader,
+ children
+}: {
+ author: string;
+ authorName?: string;
+ time: string;
+ agents: Agent[];
+ showHeader?: boolean;
+ children: React.ReactNode;
+}) {
+ const agent = resolveAuthor(author, agents, authorName);
+ return (
+
+ {showHeader === false ? : }
+
+ {showHeader === false ? null : (
+
+ {agent.name}
+
+
+ )}
+ {children}
+
+
+ );
+}
+
+function ToolGroupLine({
+ item,
+ onLoadToolDetails
+}: {
+ item: Extract;
+ onLoadToolDetails?: (toolCallSeq: number) => Promise;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const status = item.events.some(event => event.result === "pending")
+ ? "pending"
+ : item.events.some(event => event.result === "error")
+ ? "error"
+ : "ok";
+ return (
+
+ setOpen(value => !value)}
+ onKeyDown={event => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ setOpen(value => !value);
+ }}
+ >
+
+ ›
+
+ Used {item.events.length} tools
+ {open ? : {toolGroupSummary(item.events)}}
+
+
+ {open ? (
+
+ {item.events.map(tool => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function Timeline({
+ items,
+ agents,
+ onLoadToolDetails
+}: {
+ items: RenderableTimelineItem[];
+ agents: Agent[];
+ onLoadToolDetails?: (toolCallSeq: number) => Promise;
+}) {
+ return (
+
+ {items.map((item, index) => {
+ if (item.kind === "handover_divider") {
+ const from = resolveAuthor(item.from, agents, item.fromName);
+ const to = resolveAuthor(item.to, agents, item.toName);
+ return (
+
+
+
+
+
+ {from.name}
+ {item.subtype === "return" ? " returned to " : item.subtype === "escalate" ? " escalated to " : " handed off to "}
+ {to.name}
+ {item.note ? · {item.note} : null}
+
+
+
+
+
+ );
+ }
+
+ if (item.kind === "message") {
+ const author = resolveAuthor(item.author, agents, item.authorName);
+ const mine = author.human;
+ return (
+
+ {!mine ? (item.showHeader === false ? : ) : null}
+
+ {mine || item.showHeader !== false ? (
+
{author.name} · {item.time}{item.userSteer ? " · Steer" : ""}
+ ) : null}
+ {item._thought ?
: null}
+
+
+ {item.optimistic ?
Sending : null}
+
+
+ );
+ }
+
+ if (item.kind === "thinking") {
+ const author = resolveAuthor(item.author, agents, item.authorName);
+ return (
+
+ {item.showHeader === false ? : }
+
+
{author.name} · thinking · {item.time}
+
+
+
+ );
+ }
+
+ if (item.kind === "tool") {
+ return (
+
+
+
+ );
+ }
+
+ if (item.kind === "tool_group") {
+ return (
+
+
+
+ );
+ }
+
+ if (item.kind === "tool_result") {
+ return (
+
+ {item.name || "Tool"} result · {item.time}
+
+
+ );
+ }
+
+ if (item.kind === "error") {
+ return
;
+ }
+
+ return null;
+ })}
+
+ );
+}
diff --git a/packages/mobile-pwa/src/ui/ToolOutput.tsx b/packages/mobile-pwa/src/ui/ToolOutput.tsx
new file mode 100644
index 0000000..d740c9a
--- /dev/null
+++ b/packages/mobile-pwa/src/ui/ToolOutput.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+
+type ToolOutputSection = {
+ label: string;
+ text: string;
+};
+
+function isMultiline(text: string): boolean {
+ return text.includes("\n");
+}
+
+export function toolOutputSections(rawOutput: string): ToolOutputSection[] {
+ if (!rawOutput) return [];
+ try {
+ const parsed = JSON.parse(rawOutput);
+ if (typeof parsed === "string") return [{ label: "", text: parsed }];
+ return [{ label: "", text: rawOutput }];
+ } catch {
+ return [{ label: "", text: rawOutput }];
+ }
+}
+
+export function ToolOutput({ output, result }: { output: string; result?: "ok" | "pending" | "error" }) {
+ const sections = toolOutputSections(output);
+ if (sections.length === 0) return null;
+ return (
+
+ {sections.map((section, index) => (
+
0 ? "tool-output-section" : undefined}>
+ {section.label ? (
+
+ {section.label}
+
+ ) : null}
+
+ {section.text}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/mobile-pwa/src/ui/icons.tsx b/packages/mobile-pwa/src/ui/icons.tsx
new file mode 100644
index 0000000..0c6b37f
--- /dev/null
+++ b/packages/mobile-pwa/src/ui/icons.tsx
@@ -0,0 +1,39 @@
+export function BackIcon() {
+ return ‹;
+}
+
+export function MoreIcon() {
+ return (
+
+ );
+}
+
+export function StopIcon() {
+ return (
+
+ );
+}
+
+export function SendIcon() {
+ return (
+
+ );
+}
+
+export function CameraIcon() {
+ return (
+
+ );
+}
diff --git a/packages/mobile-pwa/src/vite-env.d.ts b/packages/mobile-pwa/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/packages/mobile-pwa/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/mobile-pwa/tsconfig.json b/packages/mobile-pwa/tsconfig.json
new file mode 100644
index 0000000..404c536
--- /dev/null
+++ b/packages/mobile-pwa/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/packages/mobile-pwa/vite.config.ts b/packages/mobile-pwa/vite.config.ts
new file mode 100644
index 0000000..564d0c2
--- /dev/null
+++ b/packages/mobile-pwa/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import { resolve } from "node:path";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": resolve(__dirname, "src")
+ }
+ },
+ server: {
+ port: 3001
+ },
+ preview: {
+ port: 3001
+ },
+ test: {
+ environment: "node",
+ include: ["src/__tests__/**/*.test.ts", "src/__tests__/**/*.test.tsx"]
+ }
+});
diff --git a/packages/mobile/app/agents/[agentId].tsx b/packages/mobile/app/agents/[agentId].tsx
index 4b2b192..0c60e84 100644
--- a/packages/mobile/app/agents/[agentId].tsx
+++ b/packages/mobile/app/agents/[agentId].tsx
@@ -1,6 +1,7 @@
import React from "react";
import { router, useLocalSearchParams } from "expo-router";
import { ScrollView, StyleSheet, Text, View } from "react-native";
+import { connectionIssueTitle } from "@/client/connectionIssue";
import { useMobileClient } from "@/client/MobileClientProvider";
import { Agent } from "@/api/types";
import { DesktopOfflineState } from "@/ui/DesktopOfflineState";
@@ -36,7 +37,7 @@ export default function AgentDetailScreen() {
left={}
/>
}
/>
}
/>
}
/>
}
/>
= {}) {
return JSON.stringify({
v: 1,
- type: PAIRING_TYPE,
- relay_url: "wss://relay.example.com/relay",
- server_id: "srv_test",
- desktop_name: "Studio Mac",
- daemon_pubkey: "abc123",
- pairing_id: "pair_test",
- pairing_secret: "secret",
- expires_at: future,
+ r: "wss://relay.example.com/relay",
+ s: "srv_test",
+ n: "Studio Mac",
+ k: "abc123",
+ p: "pair_test",
+ x: "secret",
+ e: future,
...overrides
});
}
@@ -28,19 +27,32 @@ describe("parsePairingOffer", () => {
});
});
- it("rejects malformed JSON", () => {
- expect(() => parsePairingOffer("{", now)).toThrow("not valid JSON");
+ it("accepts a Crew44 pair link", () => {
+ const encoded = encodeURIComponent(offer());
+ expect(parsePairingOffer(`https://mobileapp.crew44.io/#secret=${encoded}`, now)).toMatchObject({
+ relay_url: "wss://relay.example.com/relay",
+ pairing_id: "pair_test"
+ });
});
- it("rejects wrong QR types", () => {
- expect(() => parsePairingOffer(offer({ type: "other" }), now)).toThrow("not a Crew44");
+ it("accepts unencoded pair links and scanner text with a URL prefix", () => {
+ expect(parsePairingOffer(`https://mobileapp.crew44.io/#secret=${offer()}`, now)).toMatchObject({
+ pairing_id: "pair_test"
+ });
+ expect(parsePairingOffer(`https://mobileapp.crew44.io/${offer()}`, now)).toMatchObject({
+ pairing_id: "pair_test"
+ });
+ });
+
+ it("rejects malformed JSON", () => {
+ expect(() => parsePairingOffer("{", now)).toThrow("not valid JSON");
});
it("rejects expired offers", () => {
- expect(() => parsePairingOffer(offer({ expires_at: "2026-05-13T10:59:00.000Z" }), now)).toThrow("expired");
+ expect(() => parsePairingOffer(offer({ e: "2026-05-13T10:59:00.000Z" }), now)).toThrow("expired");
});
it("rejects non-websocket relay URLs", () => {
- expect(() => parsePairingOffer(offer({ relay_url: "https://relay.example.com" }), now)).toThrow("ws or wss");
+ expect(() => parsePairingOffer(offer({ r: "https://relay.example.com" }), now)).toThrow("ws or wss");
});
});
diff --git a/packages/mobile/src/client/MobileClientProvider.tsx b/packages/mobile/src/client/MobileClientProvider.tsx
index 0032d47..7301c62 100644
--- a/packages/mobile/src/client/MobileClientProvider.tsx
+++ b/packages/mobile/src/client/MobileClientProvider.tsx
@@ -3,14 +3,14 @@ import Constants from "expo-constants";
import { router } from "expo-router";
import { Alert, AppState } from "react-native";
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 { 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";
+type Status = "loading" | "unpaired" | "connecting" | "online" | "error";
interface MobileClientContextValue {
status: Status;
@@ -25,7 +25,6 @@ interface MobileClientContextValue {
const MobileClientContext = React.createContext(null);
-const relayRetryDelayMs = 1000;
const keepAliveIntervalMs = 10000;
const keepAliveTimeoutMs = 5000;
@@ -56,25 +55,16 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
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);
+ const suppressRevokedNoticeRef = 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);
@@ -91,39 +81,27 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
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") => {
+ const showDesktopTimeout = React.useCallback((message = "The Crew44 desktop did not respond within 10 seconds.") => {
if (!mountedRef.current) return;
- clearReconnectTimer();
setApi(null);
setError(message);
- setConnectionIssue("relay");
+ setConnectionIssue("desktop_timeout");
setStatus("error");
- }, [clearReconnectTimer]);
+ }, []);
- const scheduleRelayReconnect = React.useCallback((message: string) => {
+ const showRelayError = React.useCallback((message = "Relay connection failed") => {
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]);
+ setStatus("error");
+ }, []);
const classifyConnectionLoss = React.useCallback(async () => {
const saved = await loadPairing();
@@ -137,23 +115,23 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
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");
+ const handleConnectError = React.useCallback((err: unknown) => {
+ const result = classifyConnectError(err);
+ if (result.issue === "relay") {
+ showRelayError(result.message);
return;
}
- if (err instanceof RelayConnectionError) {
- scheduleRelayReconnect(err.message);
+ if (result.issue === "desktop_timeout") {
+ showDesktopTimeout(result.message);
return;
}
- showDesktopOffline(err instanceof Error ? err.message : "Can't connect to the Crew44 desktop");
- }, [scheduleRelayReconnect, showDesktopOffline]);
+ showDesktopOffline(result.message);
+ }, [showDesktopOffline, showDesktopTimeout, showRelayError]);
const handleRemoteRevoked = React.useCallback(async () => {
revokedRef.current = true;
- clearReconnectTimer();
- reconnectAttemptRef.current = 0;
closeRpc();
+ if (suppressRevokedNoticeRef.current) return;
await clearPairing();
setProfile(null);
setConnectionIssue("");
@@ -161,7 +139,7 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
setStatus("unpaired");
resetNavigationToPair();
showRemoteRevokedNotice();
- }, [clearReconnectTimer, closeRpc]);
+ }, [closeRpc]);
const pingRpc = React.useCallback(async (rpc: JsonRpcPeer) => {
let timeoutId: ReturnType | null = null;
@@ -197,16 +175,12 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
}, keepAliveIntervalMs);
}, [classifyConnectionLoss, pingRpc, showDesktopOffline, stopKeepAlive]);
- const connectStoredPairing = React.useCallback(async (options: { resetBackoff?: boolean; silent?: boolean } = {}) => {
- clearReconnectTimer();
- if (options.resetBackoff !== false) reconnectAttemptRef.current = 0;
+ const connectStoredPairing = React.useCallback(async () => {
revokedRef.current = false;
closeRpc();
- if (!options.silent) {
- setStatus("connecting");
- setError("");
- setConnectionIssue("");
- }
+ setStatus("connecting");
+ setError("");
+ setConnectionIssue("");
const saved = await loadPairing();
if (!saved) {
setProfile(null);
@@ -231,27 +205,21 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
rpcRef.current = rpc;
setApi(new CrewApi(rpc));
startKeepAlive(rpc);
- reconnectAttemptRef.current = 0;
setConnectionIssue("");
setError("");
setStatus("online");
} catch (err) {
- classifyConnectError(err);
+ handleConnectError(err);
}
- }, [classifyConnectError, classifyConnectionLoss, clearReconnectTimer, closeRpc, handleRemoteRevoked, showDesktopOffline, startKeepAlive, stopKeepAlive]);
-
- React.useEffect(() => {
- connectStoredPairingRef.current = connectStoredPairing;
- }, [connectStoredPairing]);
+ }, [classifyConnectionLoss, closeRpc, handleConnectError, handleRemoteRevoked, showDesktopOffline, startKeepAlive, stopKeepAlive]);
React.useEffect(() => {
connectStoredPairing();
return () => {
mountedRef.current = false;
- clearReconnectTimer();
closeRpc();
};
- }, [clearReconnectTimer, connectStoredPairing, closeRpc]);
+ }, [connectStoredPairing, closeRpc]);
React.useEffect(() => {
const subscription = AppState.addEventListener("change", nextState => {
@@ -273,15 +241,31 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
}, [classifyConnectionLoss, pingRpc, showDesktopOffline]);
const pairWithQrText = React.useCallback(async (text: string) => {
- clearReconnectTimer();
- reconnectAttemptRef.current = 0;
- revokedRef.current = false;
+ 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 offer = parsePairingOffer(text);
const result = await registerPairing(offer, deviceName());
await savePairing(result.profile, result.privateKey);
setProfile(result.profile);
@@ -292,15 +276,14 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
setConnectionIssue("");
throw err;
}
- }, [clearReconnectTimer, closeRpc, connectStoredPairing]);
+ }, [api, closeRpc, connectStoredPairing, profile]);
const disconnect = React.useCallback(async () => {
const currentApi = api;
const currentProfile = profile;
const desktopDeviceId = currentProfile?.deviceId || "";
const wasDesktopConnected = Boolean(currentApi && desktopDeviceId);
- clearReconnectTimer();
- reconnectAttemptRef.current = 0;
+ suppressRevokedNoticeRef.current = true;
revokedRef.current = true;
if (currentApi && desktopDeviceId) {
await currentApi.deleteRemoteDevice(desktopDeviceId).catch(() => {});
@@ -312,7 +295,8 @@ export function MobileClientProvider({ children }: { children: React.ReactNode }
setError(wasDesktopConnected ? "" : "Also unpair this device on desktop before pairing again.");
setStatus("unpaired");
resetNavigationToPair();
- }, [api, clearReconnectTimer, closeRpc, profile]);
+ suppressRevokedNoticeRef.current = false;
+ }, [api, closeRpc, profile]);
const value = React.useMemo(() => ({
status,
diff --git a/packages/mobile/src/client/classifyConnectError.ts b/packages/mobile/src/client/classifyConnectError.ts
new file mode 100644
index 0000000..6f3e37e
--- /dev/null
+++ b/packages/mobile/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/src/client/connectionIssue.ts b/packages/mobile/src/client/connectionIssue.ts
new file mode 100644
index 0000000..9994b3f
--- /dev/null
+++ b/packages/mobile/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/src/remote/pairingOffer.ts b/packages/mobile/src/remote/pairingOffer.ts
index aeac802..1c35a40 100644
--- a/packages/mobile/src/remote/pairingOffer.ts
+++ b/packages/mobile/src/remote/pairingOffer.ts
@@ -1,4 +1,5 @@
export const PAIRING_TYPE = "crew44-remote-pairing";
+export const PAIRING_SECRET_PARAM = "secret";
export interface PairingOffer {
v: number;
@@ -19,10 +20,35 @@ function requireString(value: unknown, name: string): string {
return value;
}
+export function pairingSecretFromText(text: string): string {
+ const trimmed = text.trim();
+ const hashIndex = trimmed.indexOf("#");
+ if (hashIndex >= 0) {
+ const hashText = trimmed.slice(hashIndex + 1);
+ const secret = new URLSearchParams(hashText).get(PAIRING_SECRET_PARAM);
+ if (secret) return secret;
+ const marker = `${PAIRING_SECRET_PARAM}=`;
+ const markerIndex = hashText.indexOf(marker);
+ if (markerIndex >= 0) return decodePairingSecret(hashText.slice(markerIndex + marker.length));
+ }
+ const jsonStart = trimmed.indexOf("{");
+ const jsonEnd = trimmed.lastIndexOf("}");
+ if (jsonStart >= 0 && jsonEnd > jsonStart) return trimmed.slice(jsonStart, jsonEnd + 1);
+ return trimmed;
+}
+
+function decodePairingSecret(value: string): string {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
export function parsePairingOffer(text: string, now: Date = new Date()): PairingOffer {
let raw: unknown;
try {
- raw = JSON.parse(text);
+ raw = JSON.parse(pairingSecretFromText(text));
} catch {
throw new Error("Pairing QR is not valid JSON");
}
@@ -31,18 +57,17 @@ export function parsePairingOffer(text: string, now: Date = new Date()): Pairing
}
const obj = raw as Record;
if (obj.v !== 1) throw new Error("Unsupported pairing offer version");
- if (obj.type !== PAIRING_TYPE) throw new Error("QR code is not a Crew44 pairing offer");
const offer: PairingOffer = {
v: 1,
type: PAIRING_TYPE,
- relay_url: requireString(obj.relay_url, "relay_url"),
- server_id: requireString(obj.server_id, "server_id"),
- desktop_name: typeof obj.desktop_name === "string" && obj.desktop_name.trim() ? obj.desktop_name.trim() : undefined,
- daemon_pubkey: requireString(obj.daemon_pubkey, "daemon_pubkey"),
- pairing_id: requireString(obj.pairing_id, "pairing_id"),
- pairing_secret: requireString(obj.pairing_secret, "pairing_secret"),
- expires_at: requireString(obj.expires_at, "expires_at")
+ relay_url: requireString(obj.r, "r"),
+ server_id: requireString(obj.s, "s"),
+ desktop_name: typeof obj.n === "string" && obj.n.trim() ? obj.n.trim() : undefined,
+ daemon_pubkey: requireString(obj.k, "k"),
+ pairing_id: requireString(obj.p, "p"),
+ pairing_secret: requireString(obj.x, "x"),
+ expires_at: requireString(obj.e, "e")
};
const expiresAt = new Date(offer.expires_at);
diff --git a/packages/mobile/src/remote/relay.ts b/packages/mobile/src/remote/relay.ts
index f648b54..de8dbcf 100644
--- a/packages/mobile/src/remote/relay.ts
+++ b/packages/mobile/src/remote/relay.ts
@@ -14,6 +14,13 @@ export class DesktopOfflineError extends Error {
}
}
+export class DesktopTimeoutError extends Error {
+ constructor(message = "The Crew44 desktop did not respond within 10 seconds.") {
+ super(message);
+ this.name = "DesktopTimeoutError";
+ }
+}
+
export function buildRelayClientUrl(relayUrl: string, serverId: string): string {
return buildRelayUrl(relayUrl, serverId, "client");
}
@@ -57,7 +64,7 @@ export function openRelaySocket(relayUrl: string, serverId: string): Promise {
+export async function checkRelayDesktopStatus(relayUrl: string, serverId: string): Promise<"desktop_online" | "desktop_offline" | "desktop_timeout"> {
const socket = await new Promise((resolve, reject) => {
const ws = new WebSocket(buildRelayStatusUrl(relayUrl, serverId));
const cleanup = () => {
@@ -92,10 +99,11 @@ export async function checkRelayDesktopStatus(relayUrl: string, serverId: string
export function waitForRelayReady(socket: WebSocket): Promise {
return waitForRelayStatus(socket).then(status => {
if (status === "desktop_offline") throw new DesktopOfflineError();
+ if (status === "desktop_timeout") throw new DesktopTimeoutError();
});
}
-function waitForRelayStatus(socket: WebSocket): Promise<"desktop_online" | "desktop_offline"> {
+function waitForRelayStatus(socket: WebSocket): Promise<"desktop_online" | "desktop_offline" | "desktop_timeout"> {
return new Promise((resolve, reject) => {
const cleanup = () => {
socket.removeEventListener("message", onMessage);
@@ -109,7 +117,7 @@ function waitForRelayStatus(socket: WebSocket): Promise<"desktop_online" | "desk
? event.data
: new TextDecoder().decode(await bytesFromWebSocketData(event.data));
const data = JSON.parse(text) as { type?: string };
- if (data.type === "desktop_online" || data.type === "desktop_offline") {
+ if (data.type === "desktop_online" || data.type === "desktop_offline" || data.type === "desktop_timeout") {
resolve(data.type);
return;
}
diff --git a/src/PairMobileDialog.jsx b/src/PairMobileDialog.jsx
index fb80b34..0255641 100644
--- a/src/PairMobileDialog.jsx
+++ b/src/PairMobileDialog.jsx
@@ -4,12 +4,13 @@ import { createRemotePairing, deleteRemoteDevice } from './api.js';
import { ghostBtn, primaryBtn, UI_FONT, MONO_FONT, Icon } from './components.jsx';
export const DEFAULT_RELAY_URL = 'wss://relay.crew44.io/relay';
+const MOBILE_APP_URL = 'https://mobileapp.crew44.io/';
-const iconButton = {
- width: 28,
- height: 28,
- border: '1px solid #E6DFCC',
- background: '#FCFAF1',
+const iconButtonGhost = {
+ width: 24,
+ height: 24,
+ border: '1px solid transparent',
+ background: 'transparent',
color: '#5C544B',
borderRadius: 6,
padding: 0,
@@ -17,6 +18,7 @@ const iconButton = {
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
+ transition: 'border-color 0.14s ease, background 0.14s ease',
};
function formatDate(value) {
@@ -39,14 +41,61 @@ function expiryState(pairing, now) {
}
const remainingMs = expiresAt.getTime() - now.getTime();
if (remainingMs <= 0) return { expired: true, label: 'Expired' };
- const remainingMinutes = Math.max(1, Math.ceil(remainingMs / 60000));
+ // Keep ceil semantics but subtract a tiny buffer to avoid edge flicker
+ // like "6 min" dropping to "5 min" one second later.
+ const remainingMinutes = Math.max(1, Math.ceil((remainingMs - 1000) / 60000));
return {
expired: false,
label: `Expires in ${remainingMinutes} min`,
};
}
-function ModalShell({ title, subtitle, onClose, children }) {
+function MobileAppLinkWithQr() {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ >
+
+ {MOBILE_APP_URL}
+
+ {open && (
+
+
+
+ )}
+
+ );
+}
+
+function ModalShell({ title, subtitle, onClose, children, width = 440, surfaceStyle = null }) {
+ const titleId = React.useId();
+
React.useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'Escape') onClose();
@@ -76,14 +125,16 @@ function ModalShell({ title, subtitle, onClose, children }) {
@@ -100,7 +151,7 @@ function ModalShell({ title, subtitle, onClose, children }) {
-
+
{title}
@@ -143,6 +194,7 @@ export function ManageMobileDialog({ devices = [], onClose, onChanged }) {
title="Manage mobile"
subtitle="Paired devices that can connect through the relay."
onClose={onClose}
+ width={520}
>
{error && (
@@ -150,6 +202,21 @@ export function ManageMobileDialog({ devices = [], onClose, onChanged }) {
)}
+ {items.length > 0 && (
+
+ Open the link below on your paired device to use Crew44 mobile control:
+
+ )}
+
{items.map(device => {
const pairedAt = formatDate(device.created_at);
@@ -220,8 +287,11 @@ export default function PairMobileDialog({ onClose, onChanged }) {
const [pairing, setPairing] = React.useState(null);
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
+ const [refreshHover, setRefreshHover] = React.useState(false);
+ const [copiedLink, setCopiedLink] = React.useState(false);
const [now, setNow] = React.useState(() => new Date());
const createdRef = React.useRef(false);
+ const copyResetTimerRef = React.useRef(null);
const createPairing = React.useCallback(async (url = relayUrl) => {
const trimmed = url.trim();
@@ -259,18 +329,32 @@ export default function PairMobileDialog({ onClose, onChanged }) {
}, [pairing?.offer?.expires_at]);
const expiry = expiryState(pairing, now);
+ const copyPairingLink = React.useCallback(async () => {
+ if (!pairing?.qr_text) return;
+ try {
+ await navigator.clipboard.writeText(pairing.qr_text);
+ setCopiedLink(true);
+ if (copyResetTimerRef.current) window.clearTimeout(copyResetTimerRef.current);
+ copyResetTimerRef.current = window.setTimeout(() => setCopiedLink(false), 1500);
+ } catch {}
+ }, [pairing?.qr_text]);
+
+ React.useEffect(() => () => {
+ if (copyResetTimerRef.current) window.clearTimeout(copyResetTimerRef.current);
+ }, []);
return (
-
-
- Relay URL
-
- {editingRelay ? (
+ {editingRelay && (
+
+
+ Relay URL
+
- ) : (
-
-
- {relayUrl}
-
-
setEditingRelay(true)}
- style={iconButton}
- >
-
-
-
- )}
-
+
+ )}
{error && (
@@ -347,7 +404,7 @@ export default function PairMobileDialog({ onClose, onChanged }) {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
- gap: 12,
+ gap: 8,
padding: 16,
border: '1px solid #ECE6D5',
borderRadius: 8,
@@ -393,8 +450,32 @@ export default function PairMobileDialog({ onClose, onChanged }) {
)}
-
- {expiry.label}
+
+
+ {expiry.label}
+
+ {!expiry.expired && (
+
createPairing(relayUrl)}
+ disabled={busy}
+ onMouseEnter={() => setRefreshHover(true)}
+ onMouseLeave={() => setRefreshHover(false)}
+ style={{
+ ...iconButtonGhost,
+ borderColor: 'transparent',
+ background: refreshHover ? '#F6F2E4' : 'transparent',
+ opacity: busy ? 0.65 : 1,
+ cursor: busy ? 'default' : 'pointer',
+ }}
+ >
+
+
+ )}
+
+
+ To use Crew44 on your mobile device, simply scan the QR above with your phone's camera.
>
) : (
@@ -404,21 +485,21 @@ export default function PairMobileDialog({ onClose, onChanged }) {
)}
-
- Close
- createPairing(relayUrl)}
- disabled={busy}
- style={{
- ...primaryBtn,
- opacity: busy ? 0.65 : 1,
- cursor: busy ? 'default' : 'pointer',
- }}
- >
- {busy ? 'Creating...' : 'Refresh QR'}
-
-
-
+
+
+ {copiedLink ? 'Copied' : 'Copy link'}
+
+ Close
+
+
);
}
diff --git a/src/Sidebar.jsx b/src/Sidebar.jsx
index 6d31813..7f86575 100644
--- a/src/Sidebar.jsx
+++ b/src/Sidebar.jsx
@@ -770,8 +770,7 @@ export default function Sidebar({ projects, currentChatId, route, setRoute, onPi
onClick={() => setRoute('agents')}
testId="nav-agents"
/>
- {/* Pair Mobile — feature incomplete, hidden until ready */}
- {/*
*/}
+
setRoute('auto')} testId="nav-auto" />
diff --git a/src/TaskView.jsx b/src/TaskView.jsx
index 055c7cf..f10593a 100644
--- a/src/TaskView.jsx
+++ b/src/TaskView.jsx
@@ -2940,7 +2940,7 @@ function Composer({ onSend, isStreaming, onCancel, pendingSteers = [], onCancelS
setScrollTop(ta.current.scrollTop);
}, [val]);
- React.useEffect(() => {
+ React.useLayoutEffect(() => {
if (!composerDraftKey) {
setDraftReadyKey('');
return;
diff --git a/src/__tests__/components.test.jsx b/src/__tests__/components.test.jsx
index 0e44b82..d65a80e 100644
--- a/src/__tests__/components.test.jsx
+++ b/src/__tests__/components.test.jsx
@@ -198,7 +198,7 @@ describe('RichText', () => {
// ─── Icon ──────────────────────────────────────────────────────────────────────
describe('Icon', () => {
it('renders an svg for each known name', () => {
- for (const name of ['new', 'agents', 'auto', 'search', 'folder', 'folder-open', 'gear', 'phone', 'chev', 'plus', 'trash', 'edit']) {
+ for (const name of ['new', 'agents', 'auto', 'search', 'folder', 'folder-open', 'gear', 'phone', 'chev', 'plus', 'trash', 'edit', 'app-store', 'google-play']) {
const { container } = render(
);
expect(container.querySelector('svg')).toBeInTheDocument();
}
diff --git a/src/__tests__/pair-mobile-dialog.test.jsx b/src/__tests__/pair-mobile-dialog.test.jsx
index c6a5302..ae3b3a2 100644
--- a/src/__tests__/pair-mobile-dialog.test.jsx
+++ b/src/__tests__/pair-mobile-dialog.test.jsx
@@ -11,7 +11,7 @@ import PairMobileDialog, { ManageMobileDialog } from '../PairMobileDialog.jsx';
describe('PairMobileDialog', () => {
const pairingResult = {
- qr_text: '{"type":"crew44-remote-pairing"}',
+ qr_text: 'https://mobileapp.crew44.io/#secret=%7B%22v%22%3A1%7D',
offer: { expires_at: '2026-05-13T12:00:00.000Z' },
};
@@ -31,25 +31,12 @@ describe('PairMobileDialog', () => {
expect(await screen.findByTestId('mobile-pair-qr')).toBeInTheDocument();
});
- it('lets the relay URL be overridden from the pen edit action without persisting it locally', async () => {
- const setItem = vi.fn();
- Object.defineProperty(window, 'localStorage', {
- configurable: true,
- value: { getItem: vi.fn(), setItem },
- });
+ it('does not expose relay editing controls', async () => {
render(
{}} />);
await waitFor(() => expect(createRemotePairing).toHaveBeenCalledTimes(1));
- fireEvent.click(screen.getByRole('button', { name: /edit relay url/i }));
- fireEvent.change(screen.getByLabelText('Relay URL'), {
- target: { value: 'wss://relay.example.com/relay' },
- });
- fireEvent.click(screen.getByTestId('create-mobile-pairing'));
-
- await waitFor(() => {
- expect(createRemotePairing).toHaveBeenLastCalledWith('wss://relay.example.com/relay');
- });
- expect(setItem).not.toHaveBeenCalled();
+ expect(screen.queryByText('Relay URL')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /edit relay url/i })).not.toBeInTheDocument();
});
it('shows RPC errors from pairing creation', async () => {
@@ -58,6 +45,23 @@ describe('PairMobileDialog', () => {
expect(await screen.findByRole('alert')).toHaveTextContent('relay_url is required');
});
+
+ it('renders phone-camera pairing guidance and copies the pair link', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
+ });
+ render( {}} />);
+
+ await screen.findByTestId('mobile-pair-qr');
+ expect(screen.getByText(/simply scan the QR above/i)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
+
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(pairingResult.qr_text);
+ });
+ });
});
describe('ManageMobileDialog', () => {
@@ -78,6 +82,7 @@ describe('ManageMobileDialog', () => {
expect(row).toHaveTextContent('Alex iPhone');
expect(row).toHaveTextContent('Paired');
expect(row).toHaveTextContent('Last active');
+ expect(screen.getByText('https://mobileapp.crew44.io/')).toBeInTheDocument();
});
it('omits last active when the backend has not recorded it', () => {
diff --git a/src/__tests__/sidebar-actions.test.jsx b/src/__tests__/sidebar-actions.test.jsx
index 102f6d4..cdb0924 100644
--- a/src/__tests__/sidebar-actions.test.jsx
+++ b/src/__tests__/sidebar-actions.test.jsx
@@ -41,9 +41,19 @@ describe('Sidebar empty states', () => {
expect(screen.getByText("Jordan's Mac")).toBeInTheDocument();
});
- it('keeps the mobile entry hidden while the feature is incomplete', () => {
+ it('shows the mobile pairing entry', () => {
+ const onPairMobile = vi.fn();
+ render();
+ const item = screen.getByTestId('nav-pair-mobile');
+ expect(item).toHaveTextContent('Pair Mobile');
+
+ fireEvent.click(item);
+ expect(onPairMobile).toHaveBeenCalledTimes(1);
+ });
+
+ it('labels the mobile entry as management when a device is paired', () => {
render();
- expect(screen.queryByTestId('nav-pair-mobile')).not.toBeInTheDocument();
+ expect(screen.getByTestId('nav-pair-mobile')).toHaveTextContent('Manage Mobile');
});
});
diff --git a/src/assets/app-store.svg b/src/assets/app-store.svg
new file mode 100644
index 0000000..7d153c1
--- /dev/null
+++ b/src/assets/app-store.svg
@@ -0,0 +1,13 @@
+
+
diff --git a/src/assets/google-play-store-bag.svg b/src/assets/google-play-store-bag.svg
new file mode 100644
index 0000000..56b7f5d
--- /dev/null
+++ b/src/assets/google-play-store-bag.svg
@@ -0,0 +1,96 @@
+
+
diff --git a/src/components.jsx b/src/components.jsx
index 840504c..4fc7c96 100644
--- a/src/components.jsx
+++ b/src/components.jsx
@@ -125,6 +125,10 @@ export function Icon({ name, size = 16 }) {
return ;
case 'edit':
return ;
+ case 'app-store':
+ return ;
+ case 'google-play':
+ return ;
case 'copy':
return ;
case 'check':