diff --git a/bun.lock b/bun.lock index 18ed737..36b361e 100644 --- a/bun.lock +++ b/bun.lock @@ -27,9 +27,11 @@ "@anthropic-ai/claude-agent-sdk": "0.3.263", "@boite/contracts": "workspace:*", "fflate": "0.8.3", + "web-push": "^3.6.7", }, "devDependencies": { "@types/bun": "^1.4.2", + "@types/web-push": "^3.6.4", "typescript": "^7.0.2", }, }, @@ -287,6 +289,8 @@ "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@typescript/native": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -337,6 +341,8 @@ "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -347,6 +353,8 @@ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -355,8 +363,12 @@ "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + "bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -417,6 +429,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -495,6 +509,10 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -523,6 +541,10 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -567,6 +589,10 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -643,6 +669,8 @@ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], @@ -737,6 +765,8 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], diff --git a/docs/development.md b/docs/development.md index d6a48e5..47def1b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -306,6 +306,27 @@ One more is opt-in for a different reason. `BOITE_E2E_GUARD=1` runs for a blink. Every other focus guard case is decided on a fake of the Win32 calls, so no other test creates a window. +## Phone checks + +`bun test tests/e2e/mobile.test.ts` runs the phone navigation, portrait and +landscape layouts, model sheet and browser Back, retained drafts, and the visible +message when returning to a long conversation. It uses the in-memory client in +a hidden browser. Captures go to `tests/e2e/.artifacts/mobile-*.png`. + +`bun test tests/e2e/ui.test.ts` covers a paired device against a temporary real +core, device-only settings, revocation, reconnection and an offline app shell. +`packages/core/test/push.test.ts` exercises subscription ownership and lifecycle +with a substituted push sender. It does not prove delivery by a push provider. + +For USB Android checks, use an isolated core data directory and an echo account. +Forward its port with `adb -s SERIAL reverse tcp:7337 tcp:7337`, then open the +temporary core's pairing link on `http://localhost:7337`. The browser treats +localhost as a secure context, so this exercises the service worker without a +public certificate. Remove the mapping with +`adb -s SERIAL reverse --remove tcp:7337` when finished. Actual keyboard resizing, +installation, suspension and push delivery still need a connected device; +external access needs the trusted HTTPS origin described in [phone.md](phone.md). + ## Benches ```bash diff --git a/docs/images/phone/chat.png b/docs/images/phone/chat.png new file mode 100644 index 0000000..20afb57 Binary files /dev/null and b/docs/images/phone/chat.png differ diff --git a/docs/images/phone/conversations.png b/docs/images/phone/conversations.png new file mode 100644 index 0000000..7413d5a Binary files /dev/null and b/docs/images/phone/conversations.png differ diff --git a/docs/images/phone/model-picker.png b/docs/images/phone/model-picker.png new file mode 100644 index 0000000..aa3d84c Binary files /dev/null and b/docs/images/phone/model-picker.png differ diff --git a/docs/phone.md b/docs/phone.md index f2c4287..710efd0 100644 --- a/docs/phone.md +++ b/docs/phone.md @@ -94,11 +94,61 @@ session key that came back. ## The app on the phone -The same build, under 720 px: the sidebar becomes a drawer opened from the title, -the trace panel becomes a sheet, and the composer sticks to the bottom above the -keyboard. Nothing else changes, which is the point of one build. +The browser uses mobile navigation under 720 px. Conversations lists threads +across connected machines; Activity puts waiting requests first, followed by +running and queued turns. Settings is the third destination. The header names +the machine, connection and project, and starts a new conversation. + +Model, effort and action menus open as bottom sheets. Back dismisses an open +sheet. Controls have 44 px touch targets; `visualViewport` keeps the composer +above the keyboard, and the bottom navigation hides while the keyboard is open. +Safe-area insets keep controls clear of the home indicator and screen cutouts. + +Draft text stays with its conversation. Four recent timelines are retained in +memory, each limited to 2,000 messages and 4 MB of text/image data, to preserve +reading positions across switches. The journal remains on the core. Settings +loads on demand, separately from the chat's initial JavaScript and stylesheet. + +On returning from the background or regaining a network connection, the client +replaces a socket that may have stopped responding and reloads messages and +pending requests. It never replays outstanding RPC calls. An uncertain prompt +retry keeps its `clientRequestId`: schema 11 records the accepted turn and +content fingerprint atomically, so repeating the request returns that turn. +Reusing the id with different content is refused. This applies to every driver. +Changing the thread's model, effort or other selection before retrying creates +a new request ID for that selection. + +## HTTPS and installation + +HTTP on a LAN opens the chat, but service workers and push need a secure origin. +Use HTTPS for a phone; `localhost` is the development exception. The core does +not terminate TLS. A reverse proxy serves the UI and `/rpc` on one HTTPS origin. +For example, with Caddy on the same machine as a core listening on port 7337: + +```caddyfile +boite.example.com { + encode zstd gzip + reverse_proxy 127.0.0.1:7337 +} +``` + +Replace the example hostname with a domain pointing at the proxy. Caddy needs +access to the ports required for its certificate challenge and public HTTPS. +Keep the core bound to loopback when the proxy is local. A private VPN still +needs a certificate the phone trusts for PWA features. + +Set the matching origin in General, Phone app, Public HTTPS address. A headless +core accepts `--public-url https://boite.example.com` or `BOITE_PUBLIC_URL`. +This saves `settings.publicUrl`, uses it in new pairing links, and permits that +exact browser origin at the WebSocket gate. Clearing the setting returns links +to the core's local address. No wildcard origin or forwarded header is trusted. -`packages/ui/public/sw.js` is what makes the second open instant. It is plain +On iPhone, open the pairing link in Safari, choose Share, then Add to Home Screen. +On Android, use Install Boite in Phone app or the browser's installation menu. +Open the installed icon and pair there if the browser did not carry the session +across. Installing a PWA and using Web Push require no Apple Developer account. + +`packages/ui/public/sw.js` caches the files for later opens. It is plain JavaScript that Vite copies to `dist/sw.js` untouched, and `lib/sw.ts` registers it after the first paint, never before: the registration must not delay what the user sees. It registers only where it helps, which is the core's own http(s) @@ -109,13 +159,14 @@ core behind it; both are skipped. A registration that fails is one ## What is cached, and what never is -One cache, `boite-ui-v1`. Every other cache is deleted on activate before the -worker claims its clients. +One cache, `boite-ui-v2`. Activation deletes older `boite-ui-` caches and leaves +other applications' caches alone before the worker claims its clients. | Request | Rule | |---|---| | a navigation | network first, the precached shell behind it | | `/assets/` | cache first, stored on its first whole 200 | +| `/fonts/`, `/icons/` | cache first | | `/rpc` | never cached | | anything carrying an `upgrade` header | never cached | | `/sw.js` | never cached | @@ -135,12 +186,39 @@ take the whole origin as its scope. A header test in end to end test stops the core, reloads the page and watches the shell paint anyway. -One consequence worth knowing before writing a test about it: a service worker -caches only what passed through it, and it passes through nothing until it -controls the page. On the load that registers it, the page and its assets were -already fetched, so the cache holds only what the install step precached. The -hashed files land on the load after that, which is also the honest shape of the -feature, the phone opening Boite a second time. +Installation reads the entry HTML and precaches its hashed entry assets, the +fonts, icon and manifest. Every successful navigation refreshes the offline +HTML. Network errors and HTTP 5xx responses fall back to that cached shell. +Secondary screens are cached when opened; an uncached Settings screen names +the missing connection instead of silently failing. + +## Notifications while closed + +General, Phone app enables Web Push for the current pairing. On iPhone this +requires a Home Screen web app on iOS 16.4 or later. Permission is requested from +the Enable notifications button. Send test notification reports whether the +push service accepted a test; delivery still depends on the device and service. + +The core generates VAPID keys on first use and keeps them in private journal +settings. `push.status`, `push.subscribe`, `push.unsubscribe` and `push.test` +operate only on the authenticated pairing. Subscription URLs and encryption +keys are not placed in events or logs. The encryption library loads on demand. + +Finished turns, errors, permission requests and questions trigger notifications +through the shared core event bus. Stopped turns do not. A click opens the +conversation, preserving an existing page and its drafts. The worker only opens +URLs on its own origin. Enable notifications from the machine's own page, not +while viewing it through another machine's UI. + +A connected page retains its local notification path even when push is enabled. +For its own core, it uses the service worker and the same per-thread notification +tag as push, so the latest notification replaces the previous one. A saved push +subscription is not treated as proof that a notification reached the phone. + +Disabling removes the server subscription and unsubscribes the browser. +Revocation deletes the subscription with the pairing. Push services returning +404 or 410 retire the destination. Other delivery failures leave it subscribed +and write a generic diagnostic without the provider's credential-bearing body. ## The limits @@ -151,9 +229,8 @@ feature, the phone opening Boite a second time. - Pairing is a link somebody carries over, by hand or by the QR code beside it, and it has to be opened within ten minutes. There is no discovery on the network. -- There is no Android or iOS package. The phone runs the web app, and a Tauri - mobile build is a later job. -- Nothing pushes: a notification while the app is closed does not exist, because - the only live channel is the WebSocket the page holds. +- There is no Android or iOS package. The phone runs the installed web app. +- Push is a notification channel, not background execution. The core must stay + running to run agents and send notifications; delivery is not guaranteed. - The core must be reachable. A different network, a VPN, or a firewall that blocks the port all end at the same screen. diff --git a/docs/server.md b/docs/server.md index baf0d16..445cadd 100644 --- a/docs/server.md +++ b/docs/server.md @@ -94,6 +94,9 @@ host part of the printed pairing URL to that reachable address. The core does not terminate TLS. Public access needs an HTTPS reverse proxy that forwards WebSocket upgrades and preserves `Host` and `Origin`. Serve the UI and `/rpc` from the same origin. Do not expose plain HTTP to the internet. +Set `BOITE_PUBLIC_URL` or `--public-url` to that exact HTTPS origin so pairing +links and the WebSocket origin check use it. [Phone setup](phone.md) includes +a Caddy example, installation steps and Web Push configuration. Before connecting through the proxy, add its exact browser origin, such as `https://boite.example.com`, to the core's `browserOrigins` setting. Connect the diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5e756a7..c49621f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -644,6 +644,8 @@ export interface SchedulerState { export interface Settings { /** Exact browser origins allowed to connect alongside the shell and this core's own origin. */ browserOrigins?: string[]; + /** HTTPS origin served by the reverse proxy, used in phone pairing links. */ + publicUrl?: string | null; maxConcurrentTurns: number; perAccountConcurrency: number; /** Minutes a Claude process stays warm after a turn. 0 releases it at once. */ @@ -947,6 +949,11 @@ export interface RpcMethods { 'sessions.list': { params: Record; result: PairedSession[] }; /** Forget a paired client: its sockets close and its token opens nothing any more. Owner only. */ 'sessions.revoke': { params: { sessionId: string }; result: { ok: true } }; + /** Push credentials are owned by the authenticated pairing, never a caller-supplied session id. */ + 'push.status': { params: Record; result: { publicKey: string; subscribed: boolean } }; + 'push.subscribe': { params: { endpoint: string; keys: { p256dh: string; auth: string } }; result: { ok: true } }; + 'push.unsubscribe': { params: Record; result: { ok: true } }; + 'push.test': { params: Record; result: { ok: true } }; 'projects.list': { params: Record; result: Project[] }; 'projects.add': { params: { path: string; name?: string }; result: Project }; @@ -1109,7 +1116,7 @@ export interface RpcMethods { 'threads.unsubscribe': { params: { threadId: ThreadId }; result: { ok: true } }; /** `attachments` ride with the prompt as image parts of the user message; see `ImageAttachment` for what is refused. */ - 'turns.start': { params: { threadId: ThreadId; prompt: string; attachments?: ImageAttachment[]; expectedSelectionVersion?: number }; result: Turn }; + 'turns.start': { params: { threadId: ThreadId; prompt: string; attachments?: ImageAttachment[]; expectedSelectionVersion?: number; clientRequestId?: string }; result: Turn }; 'turns.stop': { params: { threadId: ThreadId }; result: { stopped: boolean } }; /** diff --git a/packages/core/package.json b/packages/core/package.json index e0a6ee9..5b925f0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,10 +21,12 @@ "@agentclientprotocol/sdk": "1.4.0", "@anthropic-ai/claude-agent-sdk": "0.3.263", "@boite/contracts": "workspace:*", - "fflate": "0.8.3" + "fflate": "0.8.3", + "web-push": "^3.6.7" }, "devDependencies": { "@types/bun": "^1.4.2", + "@types/web-push": "^3.6.4", "typescript": "^7.0.2" } } diff --git a/packages/core/src/access.ts b/packages/core/src/access.ts index b0a1d1b..39461e7 100644 --- a/packages/core/src/access.ts +++ b/packages/core/src/access.ts @@ -25,6 +25,11 @@ import type { Connection } from './router.ts'; export const DEVICE_METHODS: ReadonlySet = new Set([ // Its own pairing, so a phone can show itself in the device list. 'sessions.list', + // Each authenticated pairing manages only its own push destination. + 'push.status', + 'push.subscribe', + 'push.unsubscribe', + 'push.test', // The sidebar and the composer's `@`. 'projects.list', 'projects.files', diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 9b26d83..e5b90bc 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -24,6 +24,7 @@ import { QuotaStore } from './quotas.ts'; import { PluginStore } from './plugins.ts'; import { Worktrees } from './worktree.ts'; import { ActivityStore } from './activity.ts'; +import { PushStore } from './push.ts'; export const CORE_VERSION: string = pkg.version; @@ -65,6 +66,7 @@ export class Core { readonly keybindings: KeybindingStore; readonly imports: ImportStore; readonly activity: ActivityStore; + readonly push: PushStore; subscribers: SubscriptionSink = { hasSubscribers: () => false, closeSession: () => undefined }; @@ -93,6 +95,7 @@ export class Core { this.keybindings = new KeybindingStore(this); this.imports = new ImportStore(this); this.activity = new ActivityStore(this); + this.push = new PushStore(this); registerModules(this); this.procs.applySettings(this.settings.get()); @@ -134,6 +137,7 @@ export class Core { } async close(): Promise { + await this.push.close(); this.activity.close(); await this.plugins.close(); await this.scheduler.drain(); diff --git a/packages/core/src/journal.ts b/packages/core/src/journal.ts index 5b117e2..4132b04 100644 --- a/packages/core/src/journal.ts +++ b/packages/core/src/journal.ts @@ -13,7 +13,7 @@ import type { Usage, } from '@boite/contracts'; -export const SCHEMA_VERSION = 10; +export const SCHEMA_VERSION = 11; const DELTA_WINDOW_MS = 16; /** What `listMessagePage` hands back: the page itself and the cursor for what is behind it. */ @@ -308,6 +308,10 @@ function migrate(db: Database): void { version = 9; } if (version < 10) { db.exec('ALTER TABLE threads ADD COLUMN speed TEXT'); version = 10; } + if (version < 11) { + db.exec('CREATE TABLE turn_requests (thread_id TEXT NOT NULL, request_id TEXT NOT NULL, fingerprint TEXT NOT NULL, turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, PRIMARY KEY(thread_id, request_id))'); + version = 11; + } db.exec(`PRAGMA user_version = ${version}`); } @@ -562,7 +566,7 @@ export class Journal { deleteThreadsOfProject(projectId: string): string[] { const rows = this.db.query('SELECT id FROM threads WHERE project_id = ?').all(projectId) as { id: string }[]; - for (const table of ['turns', 'messages', 'processes']) { + for (const table of ['turn_requests', 'turns', 'messages', 'processes']) { this.db.query(`DELETE FROM ${table} WHERE thread_id IN (SELECT id FROM threads WHERE project_id = ?)`).run(projectId); } this.db.query('DELETE FROM threads WHERE project_id = ?').run(projectId); @@ -626,6 +630,14 @@ export class Journal { return row === null ? null : toTurn(row); } + turnRequest(threadId: string, requestId: string): { fingerprint: string; turn_id: string } | null { + return this.db.query('SELECT fingerprint, turn_id FROM turn_requests WHERE thread_id = ? AND request_id = ?').get(threadId, requestId) as { fingerprint: string; turn_id: string } | null; + } + + putTurnRequest(threadId: string, requestId: string, fingerprint: string, turnId: string): void { + this.db.query('INSERT INTO turn_requests (thread_id, request_id, fingerprint, turn_id) VALUES (?, ?, ?, ?)').run(threadId, requestId, fingerprint, turnId); + } + listTurns(threadId?: string): Turn[] { const rows = threadId === undefined @@ -730,6 +742,9 @@ export class Journal { * the thread. */ listMessagePage(threadId: string, options: { beforeRowid?: number; limit: number }): MessagePage { + // Subscribers have already received buffered deltas. A reload must not replace + // those messages with an older projection while the next delta is streaming. + this.flushDeltas(); const limit = Math.max(1, Math.trunc(options.limit)); // One row past the page is what says whether anything is left behind it. const rows = diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 0f867f2..182c447 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -28,6 +28,7 @@ interface CoreFile { } export interface Flags { + publicUrl?: string; port: number; host: string; /** True once `--host` or `--lan` named an address, so the setting no longer decides. */ @@ -49,6 +50,11 @@ export function parseFlags(argv: string[]): Flags { const flag = argv[index]; const value = argv[index + 1]; switch (flag) { + case '--public-url': + if (!value || value.startsWith('--')) throw new Error('--public-url expects an HTTPS origin'); + flags.publicUrl = value; + index += 1; + break; case '--port': { const port = Number(value); if (!Number.isInteger(port) || port < 0 || port > 65535) { @@ -235,6 +241,8 @@ export function main(argv: string[]): void { const coreFile = join(dataDir, 'core.json'); const token = readToken(coreFile) ?? newToken(); const core = new Core({ dataDir, token, channel: flags.channel }); + const publicUrl = flags.publicUrl ?? process.env.BOITE_PUBLIC_URL; + if (publicUrl !== undefined) core.settings.set({ publicUrl }); const settings = core.settings.get(); const host = resolveHost(flags, settings); const server = startServer({ core, host, port: flags.port }); diff --git a/packages/core/src/modules.ts b/packages/core/src/modules.ts index 6c7a996..76e1c9f 100644 --- a/packages/core/src/modules.ts +++ b/packages/core/src/modules.ts @@ -11,9 +11,11 @@ import { registerSettingsMethods } from './settings.ts'; import { registerThreadMethods } from './threads.ts'; import { registerTraceMethods } from './trace.ts'; import { registerUsageMethods } from './usage.ts'; +import { registerPushMethods } from './push.ts'; /** Adding a module is one file plus one line here. `hello` is the server's own. */ export function registerModules(core: Core): void { + registerPushMethods(core); core.router.register('threads.activity.set', (params) => core.activity.set(params)); core.router.register('threads.activity.control', (params) => core.activity.control(params)); core.router.register('quotas.list', (params) => core.quotas.list(params.refresh)); diff --git a/packages/core/src/push.ts b/packages/core/src/push.ts new file mode 100644 index 0000000..56274b7 --- /dev/null +++ b/packages/core/src/push.ts @@ -0,0 +1,169 @@ +import { createHash, ECDH } from 'node:crypto'; +import type { RpcEvents, RpcParams } from '@boite/contracts'; +import type { PushSubscription } from 'web-push'; +import type { Core } from './core.ts'; +import { invalidParams, refused } from './errors.ts'; + +type Subscription = RpcParams<'push.subscribe'>; +type Payload = { title: string; body: string; threadId: string | null; tag: string }; +type Keys = { publicKey: string; privateKey: string }; +const SUBSCRIPTIONS = 'web-push.subscriptions'; +const KEYS = 'web-push.keys'; + +/** An authenticated phone cannot turn push delivery into a request to a local service. */ +export function validateSubscription(value: Subscription): Subscription { + let url: URL; + try { url = new URL(value?.endpoint); } catch { throw invalidParams('push.subscribe endpoint must be an HTTPS push service URL'); } + const host = url.hostname; + const allowed = host === 'fcm.googleapis.com' || host === 'updates.push.services.mozilla.com' || + host === 'web.push.apple.com' || host.endsWith('.push.apple.com') || host.endsWith('.notify.windows.com'); + if (url.protocol !== 'https:' || !allowed || url.port || url.username || url.password || url.hash || value.endpoint.length > 4096) { + throw invalidParams('push.subscribe endpoint must use HTTPS on an Apple, Google, Mozilla or Windows push service'); + } + for (const [field, size] of [['p256dh', 65], ['auth', 16]] as const) { + const key = value.keys?.[field]; + if (typeof key !== 'string' || !/^[A-Za-z0-9_-]+$/.test(key) || Buffer.from(key, 'base64url').length !== size) { + throw invalidParams(`push.subscribe keys.${field} must be a ${size}-byte base64url key`); + } + } + try { ECDH.convertKey(Buffer.from(value.keys.p256dh, 'base64url'), 'prime256v1'); } + catch { throw invalidParams('push.subscribe keys.p256dh must be a valid P-256 public key'); } + return { endpoint: url.href, keys: { ...value.keys } }; +} + +/** Loaded by the core without loading the encryption library or making a network request. */ +export class PushStore { + private readonly off: () => void; + private pending = new Set>(); + private closed = false; + private keysPromise: Promise | null = null; + + constructor(private readonly core: Core) { + this.off = core.bus.onAny((name, payload) => { + if (name === 'sessions.updated' && (payload as RpcEvents['sessions.updated']).state === 'revoked') { + this.remove((payload as RpcEvents['sessions.updated']).sessionId); + } else if (name === 'permission.requested' || name === 'question.asked') { + const request = payload as RpcEvents['permission.requested'] | RpcEvents['question.asked']; + this.notify(request.threadId, 'Needs your answer', `request-${request.id}`); + } else if (name === 'turn.finished') { + const turn = payload as RpcEvents['turn.finished']; + if (turn.status === 'done' || turn.status === 'error') { + this.notify(turn.threadId, turn.status === 'done' ? 'Done' : 'The agent encountered an error', `turn-${turn.id}`); + } + } + }); + } + + private subscriptions(): Record { + return (this.core.journal.getSetting(SUBSCRIPTIONS) as Record | null) ?? {}; + } + + private requireSession(sessionId: string | null): string { + if (!sessionId || !this.core.journal.getSession(sessionId)) throw refused('push requires a paired device session'); + return sessionId; + } + + private keys(): Promise { + this.keysPromise ??= (async () => { + const existing = this.core.journal.getSetting(KEYS) as Keys | null; + if (existing) return existing; + const { default: webpush } = await import('web-push'); + const generated = webpush.generateVAPIDKeys(); + // Secrets belong to private storage, never the append-only event payload. + this.core.journal.setSetting(KEYS, generated); + return generated; + })().catch(error => { this.keysPromise = null; throw error; }); + return this.keysPromise; + } + + async status(sessionId: string | null) { + const id = this.requireSession(sessionId); + return { publicKey: (await this.keys()).publicKey, subscribed: Boolean(this.subscriptions()[id]) }; + } + + subscribe(sessionId: string | null, input: Subscription) { + const id = this.requireSession(sessionId); + const subscription = validateSubscription(input); + const all = this.subscriptions(); + // A new pairing of the same browser replaces its old delivery destination. + for (const [key, previous] of Object.entries(all)) if (previous.endpoint === subscription.endpoint) delete all[key]; + all[id] = subscription; + this.core.journal.append({ type: 'push.subscribed', threadId: null, version: 1, payload: { sessionId: id } }, () => { + this.core.journal.setSetting(SUBSCRIPTIONS, all); + }); + return { ok: true } as const; + } + + remove(sessionId: string) { + const all = this.subscriptions(); + if (all[sessionId]) { + delete all[sessionId]; + this.core.journal.append({ type: 'push.unsubscribed', threadId: null, version: 1, payload: { sessionId } }, () => { + this.core.journal.setSetting(SUBSCRIPTIONS, all); + }); + } + return { ok: true } as const; + } + + unsubscribe(sessionId: string | null) { return this.remove(this.requireSession(sessionId)); } + + private notify(threadId: string, body: string, tag: string) { + if (this.closed) return; + const title = this.core.journal.getThread(threadId)?.title ?? 'Boite'; + for (const sessionId of Object.keys(this.subscriptions())) { + this.track(this.deliver(sessionId, { title, body, threadId, tag }).catch(() => { + this.core.log('warn', 'Web Push delivery failed; the conversation remains available in Boite'); + })); + } + } + + private track(task: Promise): Promise { + this.pending.add(task); + void task.finally(() => this.pending.delete(task)).catch(() => undefined); + return task; + } + + /** Override the sender in tests to exercise lifecycle without contacting push providers. */ + send = async (subscription: PushSubscription, payload: string, keys: Keys, topic: string): Promise => { + const { default: webpush } = await import('web-push'); + await webpush.sendNotification(subscription, payload, { + vapidDetails: { ...keys, subject: this.core.settings.get().publicUrl ?? 'https://github.com/beboite/boite' }, + TTL: 300, timeout: 10_000, urgency: 'normal', topic + }); + }; + + private async deliver(sessionId: string, payload: Payload): Promise { + const keys = await this.keys(); + if (this.closed || !this.core.journal.getSession(sessionId)) return; + const subscription = this.subscriptions()[sessionId]; + if (!subscription) return; + try { + await this.send(subscription, JSON.stringify(payload), keys, createHash('sha256').update(payload.tag).digest('base64url').slice(0, 32)); + } catch (error) { + const status = (error as { statusCode?: number }).statusCode; + if ((status === 404 || status === 410) && this.subscriptions()[sessionId]?.endpoint === subscription.endpoint) this.remove(sessionId); + // Provider errors often contain the endpoint and encryption material. Do not forward them. + throw refused(`Web Push delivery failed${status ? ` (${status})` : ''}`); + } + } + + async test(sessionId: string | null) { + const id = this.requireSession(sessionId); + if (!this.subscriptions()[id]) throw refused('Enable notifications on this device first'); + await this.track(this.deliver(id, { title: 'Boite', body: 'Notifications are connected', threadId: null, tag: 'test' })); + return { ok: true } as const; + } + + async close() { + this.closed = true; + this.off(); + await Promise.allSettled([...this.pending]); + } +} + +export function registerPushMethods(core: Core) { + core.router.register('push.status', (_params, ctx) => core.push.status(ctx.connection.identity.sessionId)); + core.router.register('push.subscribe', (params, ctx) => core.push.subscribe(ctx.connection.identity.sessionId, params)); + core.router.register('push.unsubscribe', (_params, ctx) => core.push.unsubscribe(ctx.connection.identity.sessionId)); + core.router.register('push.test', (_params, ctx) => core.push.test(ctx.connection.identity.sessionId)); +} diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e4370aa..a207965 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -213,7 +213,7 @@ export function startServer(options: ServerOptions): RunningServer { if (url.pathname === RPC_PATH) { const origin = request.headers.get('origin'); - if (!isAllowedOrigin(origin, self.port ?? 0, host) && !(origin !== null && core.settings.get().browserOrigins?.includes(origin))) { + if (!isAllowedOrigin(origin, self.port ?? 0, host) && !(origin !== null && (core.settings.get().browserOrigins?.includes(origin) || origin === core.settings.get().publicUrl))) { core.log('warn', `refused a websocket from origin ${origin ?? '(none)'}`); return new Response('forbidden origin', { status: 403 }); } diff --git a/packages/core/src/sessions.ts b/packages/core/src/sessions.ts index 4287666..7cee0cb 100644 --- a/packages/core/src/sessions.ts +++ b/packages/core/src/sessions.ts @@ -67,7 +67,7 @@ export class SessionStore { } pairingUrl(grant: string): string { - return `${this.core.baseUrl()}/?${GRANT_QUERY_PARAM}=${grant}`; + return `${this.core.settings.get().publicUrl ?? this.core.baseUrl()}/?${GRANT_QUERY_PARAM}=${grant}`; } /** diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 4c8c0ea..d57c210 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -35,6 +35,14 @@ export class SettingsStore { } set(patch: Partial): Settings { + if (patch.publicUrl !== undefined && patch.publicUrl !== null) { + try { + const url = new URL(patch.publicUrl); + if (url.protocol !== 'https:' || url.origin !== patch.publicUrl || url.username || url.password) throw new Error(); + } catch { + throw invalidParams('publicUrl must be an exact HTTPS origin without path, credentials, query or fragment', { field: 'publicUrl' }); + } + } if (patch.browserOrigins !== undefined) { const invalid = !Array.isArray(patch.browserOrigins) || patch.browserOrigins.length > 32 || patch.browserOrigins.some((origin) => { diff --git a/packages/core/src/threads.ts b/packages/core/src/threads.ts index f284a59..b0f023b 100644 --- a/packages/core/src/threads.ts +++ b/packages/core/src/threads.ts @@ -1,4 +1,5 @@ import { statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { activityPrompt } from './activity-prompt.ts'; import { relative, resolve } from 'node:path'; import { ATTACHMENTS_PER_TURN, ATTACHMENT_MAX_BYTES, IMAGE_MIME_TYPES, MESSAGE_PAGE, MESSAGE_PAGE_MAX } from '@boite/contracts'; @@ -571,8 +572,19 @@ export class ThreadStore { return this.startTurn(threadId, protocol === 'echo' ? '[compact]' : '/compact', [], expectedSelectionVersion, 'compact'); } - startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact', activity?: { kind: 'goal' | 'loop'; iteration: number }): Turn { + startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact', activity?: { kind: 'goal' | 'loop'; iteration: number }, clientRequestId?: string): Turn { const thread = this.require(threadId); + let fingerprint = ''; + if (clientRequestId !== undefined) { + if (typeof clientRequestId !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(clientRequestId)) throw refused('clientRequestId must contain 8 to 128 URL-safe characters'); + fingerprint = createHash('sha256').update(JSON.stringify([prompt, attachments.map(a => [a.mimeType, a.data, a.name])])).digest('hex'); + const existing = this.core.journal.turnRequest(threadId, clientRequestId); + if (existing) { + if (existing.fingerprint !== fingerprint) throw refused('clientRequestId was already used for different content'); + const accepted = this.core.journal.getTurn(existing.turn_id); + if (accepted) return accepted; + } + } this.checkSelection(thread, expectedSelectionVersion); if (thread.archived) throw refused('cannot start a turn on an archived thread', { threadId }); if (['queued', 'running', 'waiting'].includes(thread.status) || this.handles.has(threadId)) { @@ -626,6 +638,7 @@ export class ThreadStore { this.core.journal.append({ type: 'turn.queued', threadId, version: 1, payload: turn }, () => { this.core.journal.putTurn(turn); this.core.journal.putMessage(message); + if (clientRequestId) this.core.journal.putTurnRequest(threadId, clientRequestId, fingerprint, turn.id); }); if (!activity && !operation) this.core.activity.userPrompt(threadId); this.core.bus.emit('message.started', message); @@ -1285,7 +1298,7 @@ export function registerThreadMethods(core: Core): void { return { ok: true } as const; }); core.router.register('turns.start', (params) => - core.threads.startTurn(params.threadId, params.prompt, params.attachments ?? [], params.expectedSelectionVersion), + core.threads.startTurn(params.threadId, params.prompt, params.attachments ?? [], params.expectedSelectionVersion, undefined, undefined, params.clientRequestId), ); core.router.register('turns.stop', (params) => { core.activity.pauseAll(params.threadId); diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 358579a..5c695bc 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -24,12 +24,14 @@ describe('projects', () => { const client = await harness.connect(); const { threadId } = await echoThread(harness, client); const projectId = harness.core.threads.require(threadId).projectId; - await client.call('turns.start', { threadId, prompt: '[sleep:60000]' }); + await client.call('turns.start', { threadId, prompt: '[sleep:60000]', clientRequestId: 'remove-request' }); + expect(harness.core.journal.turnRequest(threadId, 'remove-request')).not.toBeNull(); await client.call('projects.remove', { projectId }); expect(harness.core.scheduler.state().running).toHaveLength(0); expect(harness.core.journal.listTurns(threadId)).toEqual([]); expect(harness.core.journal.listMessages(threadId)).toEqual([]); expect(harness.core.journal.getThread(threadId)).toBeNull(); + expect(harness.core.journal.turnRequest(threadId, 'remove-request')).toBeNull(); }); test('removing a project stops remaining processes before deleting their rows', async () => { const client = await harness.connect(); diff --git a/packages/core/test/journal.test.ts b/packages/core/test/journal.test.ts index 5085a6e..bfbf602 100644 --- a/packages/core/test/journal.test.ts +++ b/packages/core/test/journal.test.ts @@ -35,6 +35,13 @@ afterEach(() => { }); describe('journal', () => { + test('a history snapshot includes deltas already delivered to subscribers', () => { + journal.putMessage(sampleMessage('streaming-snapshot')); + journal.appendDelta('thr_test', 'streaming-snapshot', 0, 'already delivered'); + expect(journal.listMessagePage('thr_test', { limit: 120 }).messages[0]?.parts) + .toEqual([{ type: 'text', text: 'already delivered' }]); + }); + test('corrupt JSON names its table, row and column', () => { journal.putMessage(sampleMessage('broken-message')); journal.db.query('UPDATE messages SET parts = ? WHERE id = ?').run('{', 'broken-message'); diff --git a/packages/core/test/model-switch.test.ts b/packages/core/test/model-switch.test.ts index 61a9763..e743602 100644 --- a/packages/core/test/model-switch.test.ts +++ b/packages/core/test/model-switch.test.ts @@ -131,7 +131,7 @@ test('migration preserves legacy native sessions and persists new selections and const path = join(h.dataDir, 'migration.db'); const legacy = new Journal(path); legacy.putThread({ ...h.core.threads.require(threadId), sessionId: 'legacy-session' }); - legacy.db.exec('ALTER TABLE threads DROP COLUMN speed; ALTER TABLE threads DROP COLUMN session_generation; ALTER TABLE threads DROP COLUMN selection_version; ALTER TABLE turns DROP COLUMN execution; PRAGMA user_version = 8;'); + legacy.db.exec('DROP TABLE turn_requests; ALTER TABLE threads DROP COLUMN speed; ALTER TABLE threads DROP COLUMN session_generation; ALTER TABLE threads DROP COLUMN selection_version; ALTER TABLE turns DROP COLUMN execution; PRAGMA user_version = 8;'); legacy.close(); const migrated = new Journal(path); try { diff --git a/packages/core/test/push.test.ts b/packages/core/test/push.test.ts new file mode 100644 index 0000000..283b6ad --- /dev/null +++ b/packages/core/test/push.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { createECDH, randomBytes } from 'node:crypto'; +import { connect } from '../src/client.ts'; +import { validateSubscription } from '../src/push.ts'; +import { echoThread, startTestCore, waitFor, type TestCore } from './harness.ts'; +import { PushStore } from '../src/push.ts'; + +let harness: TestCore; +beforeEach(async () => { harness = await startTestCore(); }); +afterEach(async () => { await harness.stop(); }); +function subscription() { + const ecdh = createECDH('prime256v1'); + return { endpoint: `https://fcm.googleapis.com/fcm/send/${randomBytes(8).toString('hex')}`, keys: { + p256dh: ecdh.generateKeys().toString('base64url'), auth: randomBytes(16).toString('base64url') + } }; +} +function session() { + const grant = harness.core.sessions.grant(); + return harness.core.sessions.exchange(grant.grant, { name: 'pwa', version: 'test' }); +} + +test('only a real pairing can manage its own push subscription over RPC', async () => { + const owner = await harness.connect(); + await expect(owner.call('push.status', {})).rejects.toThrow('paired device'); + const paired = session(); + const phone = await connect(harness.url, paired.token); + try { + const before = await phone.call('push.status', {}); + expect(before.subscribed).toBe(false); + expect(Buffer.from(before.publicKey, 'base64url').length).toBe(65); + await phone.call('push.subscribe', subscription()); + expect((await phone.call('push.status', {})).subscribed).toBe(true); + const other = session(); + expect((await harness.core.push.status(other.id)).subscribed).toBe(false); + await phone.call('push.unsubscribe', {}); + expect((await phone.call('push.status', {})).subscribed).toBe(false); + } finally { phone.close(); } +}); + +test('invalid endpoints and encryption keys are refused before any network call', () => { + const valid = subscription(); + for (const endpoint of ['http://fcm.googleapis.com/send', 'https://127.0.0.1/', 'https://fcm.googleapis.com.evil.test/', 'https://fcm.googleapis.com:444/', 'https://user@fcm.googleapis.com/send']) { + expect(() => validateSubscription({ ...valid, endpoint })).toThrow('endpoint'); + } + expect(() => validateSubscription({ ...valid, keys: { ...valid.keys, auth: 'short' } })).toThrow('keys.auth'); + expect(() => validateSubscription({ ...valid, keys: { ...valid.keys, p256dh: Buffer.alloc(65).toString('base64url') } })).toThrow('keys.p256dh'); +}); + +test('test delivery uses the device subscription and drops an expired destination', async () => { + const paired = session(); + const sub = subscription(); + harness.core.push.subscribe(paired.id, sub); + const sent: string[] = []; + harness.core.push.send = async (target, payload, keys) => { + expect(target).toEqual(sub); + expect(keys.privateKey).toBeTruthy(); + sent.push(JSON.parse(payload).body); + }; + await harness.core.push.test(paired.id); + expect(sent).toEqual(['Notifications are connected']); + harness.core.push.send = async () => { throw Object.assign(new Error('private endpoint material'), { statusCode: 410 }); }; + await expect(harness.core.push.test(paired.id)).rejects.toThrow('Web Push delivery failed (410)'); + expect((await harness.core.push.status(paired.id)).subscribed).toBe(false); +}); + +test('revocation deletes the subscription and cannot deliver another test', async () => { + const paired = session(); + harness.core.push.subscribe(paired.id, subscription()); + harness.core.sessions.revoke(paired.id); + expect(harness.core.journal.getSetting('web-push.subscriptions')).toEqual({}); + await expect(harness.core.push.test(paired.id)).rejects.toThrow('paired device'); +}); + +test('a recreated push store retains its keys and delivers completed turns to the paired phone', async () => { + const paired = session(); + const before = await harness.core.push.status(paired.id); + harness.core.push.subscribe(paired.id, subscription()); + await harness.core.push.close(); + const restarted = new PushStore(harness.core); + const deliveries: { threadId: string; body: string }[] = []; + restarted.send = async (_target, payload) => { deliveries.push(JSON.parse(payload)); }; + try { + expect(await restarted.status(paired.id)).toEqual({ ...before, subscribed: true }); + const client = await harness.connect(); + const { threadId } = await echoThread(harness, client); + await client.call('turns.start', { threadId, prompt: 'Notify the phone' }); + await waitFor(() => deliveries.length > 0); + expect(deliveries).toEqual([expect.objectContaining({ threadId, body: 'Done' })]); + harness.core.sessions.revoke(paired.id); + expect(harness.core.journal.getSetting('web-push.subscriptions')).toEqual({}); + } finally { await restarted.close(); } +}); + +test('public HTTPS origin is validated, used for QR links and accepted by the socket', async () => { + const owner = await harness.connect(); + for (const publicUrl of ['http://phone.test', 'https://phone.test/path', 'https://user:pass@phone.test', 'https://phone.test?token=x']) { + await expect(owner.call('settings.set', { publicUrl })).rejects.toThrow('publicUrl'); + } + await owner.call('settings.set', { publicUrl: 'https://phone.test' }); + const grant = await owner.call('pairing.grant', {}); + expect(new URL(grant.url).origin).toBe('https://phone.test'); + const socket = new WebSocket(harness.url.replace('http:', 'ws:') + '/rpc', { headers: { Origin: 'https://phone.test' } }); + await new Promise((resolve, reject) => { socket.onopen = () => resolve(); socket.onerror = reject; }); + socket.close(); + await owner.call('settings.set', { publicUrl: null }); + expect(new URL((await owner.call('pairing.grant', {})).url).origin).toBe(harness.url); +}); diff --git a/packages/core/test/turn-retry.test.ts b/packages/core/test/turn-retry.test.ts new file mode 100644 index 0000000..74a7565 --- /dev/null +++ b/packages/core/test/turn-retry.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test'; +import { echoThread, startTestCore, waitFor } from './harness.ts'; + +test('resending an accepted request returns its turn even after completion and refuses changed content', async () => { + const h = await startTestCore(); + try { + const client = await h.connect(); + const { threadId } = await echoThread(h, client); + const params = { threadId, prompt: 'once', clientRequestId: 'retry-test-1' }; + const [first, concurrent] = await Promise.all([client.call('turns.start', params), client.call('turns.start', params)]); + expect(concurrent.id).toBe(first.id); + await waitFor(() => h.core.journal.getTurn(first.id)?.status === 'done'); + const retry = await client.call('turns.start', params); + expect(retry.id).toBe(first.id); + expect(h.core.journal.listTurns(threadId)).toHaveLength(1); + await expect(client.call('turns.start', { ...params, prompt: 'changed' })).rejects.toThrow('clientRequestId'); + } finally { await h.stop(); } +}); diff --git a/packages/ui/index.html b/packages/ui/index.html index 22cd5ac..93338fd 100644 --- a/packages/ui/index.html +++ b/packages/ui/index.html @@ -4,6 +4,9 @@ + + + diff --git a/packages/ui/public/sw.js b/packages/ui/public/sw.js index bdd2e51..1b3d332 100644 --- a/packages/ui/public/sw.js +++ b/packages/ui/public/sw.js @@ -7,10 +7,10 @@ * read from disk instead of the wire. What it must never touch: the RPC socket, * which is the only thing that carries live state. * - * Bump CACHE whenever the strategy below changes. `activate` deletes every - * other cache, so an old name is gone the moment a new worker takes over. + * Bump CACHE whenever the strategy below changes. `activate` deletes older + * Boite caches when a new worker takes over, leaving other apps alone. */ -const CACHE = 'boite-ui-v1'; +const CACHE = 'boite-ui-v2'; const SHELL = '/'; const MANIFEST = '/manifest.webmanifest'; const WORKER = '/sw.js'; @@ -23,7 +23,12 @@ self.addEventListener('install', (event) => { const cache = await caches.open(CACHE); // The manifest is precached so a host that does not serve it fails the // install loudly; it is still read from the network on every load, below. - await cache.addAll([SHELL, MANIFEST]); + const shell = await fetch(SHELL, { cache: 'reload' }); + if (!shell.ok) throw new Error('the app shell could not be cached'); + const html = await shell.clone().text(); + const assets = [...new Set(html.match(/\/assets\/[^\s"'<>]+/g) ?? [])]; + await cache.addAll([...assets, MANIFEST, '/icons/icon-192.png', '/fonts/Geist-Variable.woff2', '/fonts/GeistMono-Variable.woff2']); + await cache.put(SHELL, shell); await self.skipWaiting(); })(), ); @@ -33,7 +38,7 @@ self.addEventListener('activate', (event) => { event.waitUntil( (async () => { const names = await caches.keys(); - await Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))); + await Promise.all(names.filter((name) => name.startsWith('boite-ui-') && name !== CACHE).map((name) => caches.delete(name))); await self.clients.claim(); })(), ); @@ -60,7 +65,7 @@ self.addEventListener('fetch', (event) => { return; } - if (url.pathname.startsWith(ASSETS_PREFIX)) { + if (url.pathname.startsWith(ASSETS_PREFIX) || url.pathname.startsWith('/fonts/') || url.pathname.startsWith('/icons/')) { event.respondWith(assetFromCacheFirst(request)); } }); @@ -72,7 +77,13 @@ self.addEventListener('fetch', (event) => { */ async function shellFromNetworkFirst(request) { try { - return await fetch(request); + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE); + await cache.put(SHELL, response.clone()); + } + if (response.status >= 500) throw new Error('core unavailable'); + return response; } catch (error) { const cache = await caches.open(CACHE); // The request carries the pairing query, the precached entry does not, so @@ -83,6 +94,33 @@ async function shellFromNetworkFirst(request) { } } +self.addEventListener('push', (event) => { + event.waitUntil((async () => { + let payload = {}; + try { payload = event.data?.json() ?? {}; } catch { /* Show a generic notification for an empty push. */ } + const threadId = typeof payload.threadId === 'string' ? payload.threadId : null; + await self.registration.showNotification(typeof payload.title === 'string' ? payload.title : 'Boite', { + body: typeof payload.body === 'string' ? payload.body : '', + icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', + tag: threadId ? `thread-${threadId}` : typeof payload.tag === 'string' ? payload.tag : 'boite-update', + data: { threadId } + }); + })()); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const threadId = event.notification.data?.threadId; + const url = new URL('/', self.location.origin); + if (typeof threadId === 'string') url.searchParams.set('thread', threadId); + event.waitUntil((async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const existing = windows.find(client => new URL(client.url).origin === url.origin); + if (existing) { existing.postMessage({ type: 'boite.open-thread', threadId: typeof threadId === 'string' ? threadId : null }); await existing.focus(); } + else await self.clients.openWindow(url.href); + })()); +}); + /** * A file under `/assets/` carries its content hash in its name, so the copy in * the cache is the file. A new build asks for new names and stores those. diff --git a/packages/ui/src/App.svelte b/packages/ui/src/App.svelte index 0f0454e..78bd06a 100644 --- a/packages/ui/src/App.svelte +++ b/packages/ui/src/App.svelte @@ -10,7 +10,6 @@ import ProjectPicker from './components/ProjectPicker.svelte'; import ImportDialog from './components/ImportDialog.svelte'; import RightPanel from './components/RightPanel.svelte'; - import SettingsShell from './components/SettingsShell.svelte'; import Sidebar from './components/Sidebar.svelte'; import TitleBar from './components/TitleBar.svelte'; import { Closing } from './lib/closing.svelte'; @@ -24,11 +23,60 @@ import { rightPanel } from './lib/right-panel.svelte'; import { workspace } from './lib/workspace.svelte'; import { startTheme } from './lib/theme'; + import MobileNavigation from './components/MobileNavigation.svelte'; + import { startViewport } from './lib/viewport'; + import { WsClient } from './lib/client'; + import { listenForInstall } from './lib/pwa'; let store = $derived(workspace.active); const inShell = window.__TAURI_INTERNALS__ !== undefined; let sidebar = $state(undefined); let appRoot = $state(undefined); + let mobileScreen = $state<'chat' | 'threads' | 'activity'>('chat'); + let SettingsShell = $state(); + let settingsLoadError = $state(''); + $effect(() => { + if (store.page !== 'settings' || SettingsShell) return; + settingsLoadError = ''; + void import('./components/SettingsShell.svelte').then(module => { SettingsShell = module.default; }) + .catch(() => { settingsLoadError = strings.phone.settingsOffline; }); + }); + + onMount(() => { + const stopViewport = startViewport(); + const stopInstall = listenForInstall(); + let hidden = document.hidden; + const resume = () => { + if (document.hidden) return; + for (const machine of workspace.machines) { + const client = machine.store.client; + if (client instanceof WsClient) void client.resume().catch(() => undefined); + } + }; + const visibility = () => { + if (document.hidden) hidden = true; + else if (hidden) { hidden = false; resume(); } + }; + const pageshow = (event: PageTransitionEvent) => { if (event.persisted) resume(); }; + document.addEventListener('visibilitychange', visibility); + window.addEventListener('online', resume); + window.addEventListener('pageshow', pageshow); + const notification = (event: MessageEvent) => { + if (event.data?.type !== 'boite.open-thread' || typeof event.data.threadId !== 'string') return; + const machine = workspace.machines.find(m => m.store.endpointUrl && new URL(m.store.endpointUrl).origin === location.origin); + if (machine) void workspace.select(machine.store, event.data.threadId); + mobileScreen = 'chat'; + }; + navigator.serviceWorker?.addEventListener('message', notification); + return () => { + stopViewport(); + stopInstall(); + document.removeEventListener('visibilitychange', visibility); + window.removeEventListener('online', resume); + window.removeEventListener('pageshow', pageshow); + navigator.serviceWorker?.removeEventListener('message', notification); + }; + }); // The three overlays of this file leave the way they arrived: one `--dur-2` // playing the reverse animation, then out of the DOM on `animationend`. @@ -91,7 +139,14 @@ }); onMount(() => { - void workspace.boot(); + const requestedThread = new URLSearchParams(location.search).get('thread'); + void workspace.boot().then(async () => { + if (requestedThread) { + const url = new URL(location.href); url.searchParams.delete('thread'); history.replaceState(history.state, '', url); + const machine = workspace.machines.find(m => m.store.endpointUrl && new URL(m.store.endpointUrl).origin === location.origin); + if (machine) await workspace.select(machine.store, requestedThread); + } + }); // The stored theme, and the OS one while the setting reads `system`. const stopTheme = startTheme(); // The stored window material, which only the shell wears. @@ -235,10 +290,11 @@ -
+
+ {#if !inShell && store.booted}{/if} -
+
{#if !store.booted}

{strings.app.loading}

{:else if store.connection === 'closed' && !store.core} @@ -251,10 +307,10 @@
{#if store.page === 'settings'} - + {#if SettingsShell}{:else}

{settingsLoadError || strings.app.loading}

{/if} {/if} {:else if store.page === 'settings'} - + {#if SettingsShell}{:else}

{settingsLoadError || strings.app.loading}

{/if} {:else} {#if scrim.shown} @@ -462,6 +518,11 @@ } @media (max-width: 720px) { + .app:not(.shell) { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr); height: var(--app-height, 100dvh); top: var(--app-top, 0px); } + .app:not(.shell) :global(.titlebar) { display: none; grid-row: 2; grid-column: 1; } + .app.phone-chat :global(.titlebar) { display: flex; } + .app:not(.shell) .body { grid-row: 3; grid-column: 1; } + .body.mobile-covered { visibility: hidden; pointer-events: none; } .scrim { display: block; position: fixed; diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index d27e3fd..ec4208e 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -81,6 +81,7 @@ --control-lg: 36px; --control-touch: 40px; --input: 34px; + --touch-target: 44px; --dur-1: 90ms; --dur-2: 150ms; @@ -761,6 +762,12 @@ html[data-glass] .composer { .settings input[type=checkbox][role=switch]:focus-visible { outline: 2px solid var(--color-foreground); outline-offset: 4px; } .settings input[type=checkbox][role=switch]:disabled { opacity: .45; cursor: not-allowed; } @media (max-width: 720px) { + .mobile-sheet-backdrop { position: fixed; inset: 0; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 0; border: 0; border-radius: 0; background: var(--color-scrim); } + [data-mobile-sheet='true'] { position: fixed !important; box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; border-radius: var(--radius-xl) !important; padding-bottom: max(12px, env(safe-area-inset-bottom)); } + :root { --control: var(--touch-target); --control-sm: var(--touch-target); --input: var(--touch-target); --row: var(--touch-target); } + input, textarea { font-size: var(--text-md); } + button.icon { min-width: var(--touch-target); min-height: var(--touch-target); } + .composer-wrap { padding-bottom: max(10px, env(safe-area-inset-bottom)); } .settings .page { padding: 24px 16px; } .settings .page > .card { padding: 18px; } .settings .page .row { flex-wrap: wrap; } diff --git a/packages/ui/src/components/Composer.svelte b/packages/ui/src/components/Composer.svelte index 225caf1..2535469 100644 --- a/packages/ui/src/components/Composer.svelte +++ b/packages/ui/src/components/Composer.svelte @@ -1076,6 +1076,7 @@ } @media (max-width: 720px) { + textarea, .input-mirror { font-size: var(--text-md); } .composer-wrap { padding: 6px 10px 10px; } diff --git a/packages/ui/src/components/Composer.test.ts b/packages/ui/src/components/Composer.test.ts index e1b7f44..58ed21a 100644 --- a/packages/ui/src/components/Composer.test.ts +++ b/packages/ui/src/components/Composer.test.ts @@ -821,7 +821,7 @@ test('queued prompts survive settings and wait for a ready connection', async () store.connection = 'ready'; await waitFor(() => store.busy); expect(rpc.mock.calls.filter(([method]) => method === 'turns.start')).toEqual([ - ['turns.start', { threadId: 't-trace', prompt: 'queue through settings', expectedSelectionVersion: 0 }] + ['turns.start', { threadId: 't-trace', prompt: 'queue through settings', expectedSelectionVersion: 0, clientRequestId: expect.stringMatching(/^[a-f0-9]{32}$/) }] ]); }); diff --git a/packages/ui/src/components/EffortSlider.svelte b/packages/ui/src/components/EffortSlider.svelte index 692b45c..aa6b8e0 100644 --- a/packages/ui/src/components/EffortSlider.svelte +++ b/packages/ui/src/components/EffortSlider.svelte @@ -2,6 +2,7 @@ import { Brain, Zap } from '@lucide/svelte'; import type { EffortLevel } from '@boite/contracts'; import { Closing } from '../lib/closing.svelte'; + import { floating } from '../lib/floating'; import { strings } from '../lib/strings'; /** @@ -159,6 +160,7 @@ aria-label={strings.composer.effortTitle} data-testid="composer-effort-menu" use:popover.attach + use:floating={{ anchor: () => trigger ?? null, mobileOnly: true, dismiss: () => popover.hide() }} onanimationend={popover.end} {onkeydown} > diff --git a/packages/ui/src/components/GeneralSettings.svelte b/packages/ui/src/components/GeneralSettings.svelte index b911a13..1cdc075 100644 --- a/packages/ui/src/components/GeneralSettings.svelte +++ b/packages/ui/src/components/GeneralSettings.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import type { PairedSession } from '@boite/contracts'; import ShellSettings from './ShellSettings.svelte'; + import PhoneSettings from './PhoneSettings.svelte'; import ModelDefaultsSettings from './ModelDefaultsSettings.svelte'; import { confirm } from '../lib/confirm.svelte'; import { ago, time } from '../lib/format'; @@ -77,6 +78,7 @@ {#if inShell}{/if} +
diff --git a/packages/ui/src/components/Menu.svelte b/packages/ui/src/components/Menu.svelte index 953299e..65bf87b 100644 --- a/packages/ui/src/components/Menu.svelte +++ b/packages/ui/src/components/Menu.svelte @@ -2,6 +2,7 @@ import { tick, type Snippet } from 'svelte'; import { Settings } from '@lucide/svelte'; import { Closing } from '../lib/closing.svelte'; + import { floating } from '../lib/floating'; import type { MenuItem } from '../lib/menu'; let { @@ -123,6 +124,7 @@ tabindex="-1" {onkeydown} use:popover.attach + use:floating={{ anchor: () => trigger ?? null, mobileOnly: true, dismiss: () => popover.hide() }} onanimationend={popover.end} data-testid={testid ? `${testid}-menu` : undefined} > diff --git a/packages/ui/src/components/MessageList.svelte b/packages/ui/src/components/MessageList.svelte index 9dae3bc..ac7049c 100644 --- a/packages/ui/src/components/MessageList.svelte +++ b/packages/ui/src/components/MessageList.svelte @@ -9,6 +9,7 @@ + +
+ {#if screen === 'chat' && store.page === 'chat'} + + {/if} +
+ {machine?.label} · {strings.connection[store.connection]} + + {project?.name ?? strings.mobile.project} + +
+ +
+ +{#if store.page === 'chat' && screen !== 'chat'} +
+
+

{screen === 'activity' ? strings.mobile.activity : strings.mobile.threads}

+

{screen === 'activity' ? strings.mobile.activityHint : strings.mobile.threadsHint}

+ +
+ {#each rows as row (`${row.machine.id}:${row.thread.id}`)} + + {:else} +

{screen === 'activity' ? strings.mobile.noActivity : strings.mobile.noThreads}

+ {/each} +
+{/if} + + + + diff --git a/packages/ui/src/components/ModelPicker.svelte b/packages/ui/src/components/ModelPicker.svelte index ed946bb..4dc2c47 100644 --- a/packages/ui/src/components/ModelPicker.svelte +++ b/packages/ui/src/components/ModelPicker.svelte @@ -413,7 +413,7 @@ aria-label={strings.composer.picker} data-testid="composer-picker-menu" bind:this={menu} - use:floating={{ anchor: () => root?.closest("[data-testid=composer]") ?? null }} + use:floating={{ anchor: () => root?.closest("[data-testid=composer]") ?? null, dismiss: () => popover.hide() }} use:popover.attach onanimationend={popover.end} {onkeydown} diff --git a/packages/ui/src/components/PhoneSettings.svelte b/packages/ui/src/components/PhoneSettings.svelte new file mode 100644 index 0000000..38d272f --- /dev/null +++ b/packages/ui/src/components/PhoneSettings.svelte @@ -0,0 +1,113 @@ + + +
+

{strings.phone.heading}

+ {#if store.owner} + +

{strings.phone.publicUrlHint}

+ + {/if} + {#if !inShell} + {#if standalone}

{strings.phone.installed}

+ {:else} +

{strings.phone.installHint}

+ + {/if} + {#if !secure}

{strings.phone.httpsRequired}

+ {:else if !ownOrigin}

{strings.phone.ownOrigin}

+ {:else if !paired}

{strings.phone.pairFirst}

+ {:else if !capable}

{strings.phone.unsupported}

+ {:else} +

{strings.phone.pushHint}

+
+ {#if subscribed} + + + {:else}{/if} +
+ {/if} + {#if message}

{message}

{/if} + {#if error}

{error}

{/if} + {/if} +
+ + diff --git a/packages/ui/src/components/SettingsShell.svelte b/packages/ui/src/components/SettingsShell.svelte index 53f2d0e..7f144c7 100644 --- a/packages/ui/src/components/SettingsShell.svelte +++ b/packages/ui/src/components/SettingsShell.svelte @@ -35,6 +35,7 @@ keyboard: [{id: 'shortcuts', label: strings.keyboard.heading}, {id: 'keybinding-file', label: strings.keyboard.file}], experiments: [{id: 'theme-grain', label: strings.experiments.themeGrain.title}, {id: 'session-import', label: strings.experiments.sessionImport.title}], general: [ + { id: 'phone', label: strings.phone.heading }, { id: 'projects', label: strings.settings.projects }, { id: 'background', label: strings.settings.background }, { id: 'machines', label: strings.machines.heading }, @@ -211,10 +212,11 @@ nav { width: auto; - display: grid; - grid-template-columns: 1fr 1fr; - overflow-y: auto; - max-height: 220px; + display: flex; + flex-direction: row; + overflow-x: auto; + overflow-y: hidden; + padding: 8px 12px; align-items: flex-start; border-right: none; border-bottom: 1px solid var(--color-border); @@ -223,7 +225,7 @@ .subcategories { display: none; } .mobile-subcategories { display: flex; gap: 4px; padding: 8px 14px; overflow-x: auto; flex: none; border-bottom: 1px solid var(--color-border); } .mobile-subcategories button { flex: none; font-size: var(--text-sm); } - .back { grid-column: 1 / -1; margin-bottom: 0; } + .back { display: none; } .category { min-width: 0; } h1 { display: none; diff --git a/packages/ui/src/lib/client.test.ts b/packages/ui/src/lib/client.test.ts index 63a981d..256d581 100644 --- a/packages/ui/src/lib/client.test.ts +++ b/packages/ui/src/lib/client.test.ts @@ -84,6 +84,29 @@ describe('rpcUrl', () => { }); describe('WsClient', () => { + test('resuming replaces a half-open socket without replaying a pending prompt', async () => { + const sockets: FakeSocket[] = []; + const client = new WsClient({ url: 'https://core.test', token: 'session', socketFactory: () => { + const socket = new FakeSocket(); sockets.push(socket); return socket; + } }); + const connecting = client.connect(); + const first = take(sockets, 0); + first.open(); + first.receive({ id: first.frame(0).id, result: { core: CORE, principal: 'session' } }); + await connecting; + const pending = client.call('turns.start', { threadId: 'thread', prompt: 'once' }).catch(error => error); + const resumed = client.resume(); + expect(first.closed).toBe(true); + const second = take(sockets, 1); + second.open(); + second.receive({ id: second.frame(0).id, result: { core: CORE, principal: 'session' } }); + await resumed; + expect(await pending).toBeInstanceOf(Error); + expect(second.sent.map(raw => JSON.parse(raw).method)).toEqual(['hello']); + client.close(); + await client.resume(); + expect(sockets).toHaveLength(2); + }); test('hello is the first frame, and it carries the token', async () => { const sockets: FakeSocket[] = []; const client = new WsClient({ diff --git a/packages/ui/src/lib/client.ts b/packages/ui/src/lib/client.ts index 23b545c..f041c6a 100644 --- a/packages/ui/src/lib/client.ts +++ b/packages/ui/src/lib/client.ts @@ -150,6 +150,8 @@ export class WsClient implements ObservableClient { #attempt = 0; #retryTimer: ReturnType | null = null; #manuallyClosed = false; + #opening: Promise | null = null; + #cancelOpen: (() => void) | null = null; constructor(options: WsClientOptions) { this.#options = { @@ -199,8 +201,29 @@ export class WsClient implements ObservableClient { connect(): Promise { if (this.#state === 'ready' && this.#core) return Promise.resolve(this.#core); + if (this.#opening) return this.#opening; this.#manuallyClosed = false; - return this.#open(); + return this.#startOpen(); + } + + /** A mobile browser can retain a dead socket after sleep without firing close. */ + async resume(): Promise { + if (this.#manuallyClosed || this.#state === 'idle') return; + // A one-time grant must finish its exchange before any connection replaces it. + if (this.#grant !== null && this.#opening) { await this.#opening; return; } + if (this.#retryTimer !== null) clearTimeout(this.#retryTimer); + this.#retryTimer = null; + this.#teardown('connection resumed; check the conversation before resending'); + await this.#startOpen(); + } + + #startOpen(): Promise { + const opening = this.#open(); + this.#opening = opening; + void opening.finally(() => { + if (this.#opening === opening) this.#opening = null; + }).catch(() => undefined); + return opening; } call(method: M, params: RpcParams): Promise> { @@ -256,11 +279,17 @@ export class WsClient implements ObservableClient { return new Promise((resolve, reject) => { let settled = false; + const timeout = setTimeout(() => { + fail('connection did not answer within 10 seconds'); + socket.close(); + }, 10_000); const fail = (message: string) => { if (settled) return; settled = true; + clearTimeout(timeout); reject(transportFailure(message)); }; + this.#cancelOpen = () => fail('connection replaced'); socket.onmessage = (event) => this.#receive(event.data); socket.onerror = () => fail('socket error'); @@ -305,12 +334,14 @@ export class WsClient implements ObservableClient { this.#resubscribe(socket); if (!settled) { settled = true; + clearTimeout(timeout); resolve(result.core); } }, (error: unknown) => { if (!settled) { settled = true; + clearTimeout(timeout); reject(error instanceof Error ? error : transportFailure(String(error))); } // The hello parameters are fixed for this client. Retrying a @@ -349,7 +380,7 @@ export class WsClient implements ObservableClient { this.#retryTimer = setTimeout(() => { this.#retryTimer = null; if (this.#manuallyClosed) return; - void this.#open().catch(() => undefined); + void this.#startOpen().catch(() => undefined); }, delay); } @@ -360,6 +391,9 @@ export class WsClient implements ObservableClient { } #teardown(message: string): void { + this.#cancelOpen?.(); + this.#cancelOpen = null; + this.#opening = null; const socket = this.#socket; this.#socket = null; if (socket) { diff --git a/packages/ui/src/lib/fake-client.ts b/packages/ui/src/lib/fake-client.ts index c8be4e8..60a9dae 100644 --- a/packages/ui/src/lib/fake-client.ts +++ b/packages/ui/src/lib/fake-client.ts @@ -61,6 +61,7 @@ export interface FakeClientOptions { */ const DEVICE_METHODS: ReadonlySet = new Set([ 'sessions.list', + 'push.status', 'push.subscribe', 'push.unsubscribe', 'push.test', 'projects.list', 'projects.files', 'providers.list', @@ -394,6 +395,7 @@ export class FakeClient implements ObservableClient { /** The current output of every active fake login, also returned after reconnect. */ #logins = new Map(); #seq = 0; + #turnRequests = new Map(); #delayMs: number; #long: boolean; #principal: Principal; @@ -633,6 +635,12 @@ export class FakeClient implements ObservableClient { } case 'sessions.list': return structuredClone(this.#sessions); + case 'push.status': + return { publicKey: '', subscribed: false }; + case 'push.subscribe': + case 'push.unsubscribe': + case 'push.test': + throw new RpcFailure({ code: RpcErrorCode.Refused, message: 'Web Push needs a paired connection to a real core' }); case 'sessions.revoke': { const params = rawParams as RpcParams<'sessions.revoke'>; if (!this.#sessions.some((session) => session.id === params.sessionId)) { @@ -998,10 +1006,20 @@ export class FakeClient implements ObservableClient { case 'turns.start': { const params = rawParams as RpcParams<'turns.start'>; + const key = params.clientRequestId ? `${params.threadId}:${params.clientRequestId}` : null; + const content = JSON.stringify([params.prompt, (params.attachments ?? []).map(a => [a.mimeType, a.data, a.name])]); + if (params.clientRequestId !== undefined && !/^[A-Za-z0-9_-]{8,128}$/.test(params.clientRequestId)) throw new RpcFailure({ code: RpcErrorCode.Refused, message: 'clientRequestId must contain 8 to 128 URL-safe characters' }); + const previous = key ? this.#turnRequests.get(key) : undefined; + if (previous) { + if (previous.content !== content) throw new RpcFailure({ code: RpcErrorCode.Refused, message: 'clientRequestId was already used for different content' }); + return previous.turn; + } if (params.expectedSelectionVersion !== undefined && params.expectedSelectionVersion !== (this.#thread(params.threadId).selectionVersion ?? 0)) { throw new RpcFailure({ code: RpcErrorCode.Refused, message: 'the model selection changed; review the selected model and send again' }); } - return this.#startTurn(params.threadId, params.prompt, params.attachments ?? []); + const turn = this.#startTurn(params.threadId, params.prompt, params.attachments ?? []); + if (key) this.#turnRequests.set(key, { content, turn }); + return turn; } case 'threads.activity.set': { const params = rawParams as RpcParams<'threads.activity.set'>; diff --git a/packages/ui/src/lib/floating.ts b/packages/ui/src/lib/floating.ts index e37a134..16d4164 100644 --- a/packages/ui/src/lib/floating.ts +++ b/packages/ui/src/lib/floating.ts @@ -1,5 +1,21 @@ -/** Position an overlay without changing the page's layout. */ -export function floating(node: HTMLElement, options: { anchor: () => HTMLElement | null; side?: 'right' }) { +import { mobileOverlay } from './mobile-history'; +import { strings } from './strings'; +/** Position desktop popovers at their anchor and phone menus above the keyboard. */ +export function floating(node: HTMLElement, options: { anchor: () => HTMLElement | null; side?: 'right'; mobileOnly?: boolean; dismiss?: () => void }) { + const mobile = window.matchMedia('(max-width: 720px)').matches; + if (options.mobileOnly && !mobile) return {}; + let backdrop: HTMLButtonElement | null = null; + let closeHistory = () => {}; + if (mobile && options.dismiss) { + backdrop = document.createElement('button'); + backdrop.className = 'mobile-sheet-backdrop'; + backdrop.setAttribute('popover', 'manual'); + backdrop.setAttribute('aria-label', strings.common.close); + backdrop.onclick = options.dismiss; + document.body.append(backdrop); + backdrop.showPopover?.(); + closeHistory = mobileOverlay(options.dismiss); + } // The composer's glass blur establishes a containing block for fixed children. // The top layer keeps viewport coordinates valid without moving the DOM node, // so the picker's outside-click and keyboard handlers still own both menus. @@ -14,7 +30,18 @@ export function floating(node: HTMLElement, options: { anchor: () => HTMLElement if (!anchor) return; const box = anchor.getBoundingClientRect(); const width = window.innerWidth; - const height = window.innerHeight; + const viewport = window.visualViewport; + const height = viewport?.height ?? window.innerHeight; + const offset = viewport?.offsetTop ?? 0; + if (mobile) { + node.dataset.mobileSheet = 'true'; + node.style.width = `${width - margin * 2}px`; + node.style.maxHeight = `${Math.max(0, height - margin * 2) * .75}px`; + node.style.top = `${Math.max(offset + margin, offset + height - node.offsetHeight - margin)}px`; + node.style.left = `${margin}px`; + node.style.transformOrigin = 'bottom center'; + return; + } let top: number; let left: number; if (options.side === 'right') { @@ -44,5 +71,7 @@ export function floating(node: HTMLElement, options: { anchor: () => HTMLElement place(); window.addEventListener('resize', place); window.addEventListener('scroll', place, true); - return { destroy() { observer.disconnect(); anchor?.removeEventListener('animationend', place); window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); } }; + window.visualViewport?.addEventListener('resize', place); + window.visualViewport?.addEventListener('scroll', place); + return { destroy() { closeHistory(); backdrop?.remove(); observer.disconnect(); anchor?.removeEventListener('animationend', place); window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); window.visualViewport?.removeEventListener('resize', place); window.visualViewport?.removeEventListener('scroll', place); } }; } diff --git a/packages/ui/src/lib/mobile-history.ts b/packages/ui/src/lib/mobile-history.ts new file mode 100644 index 0000000..673248d --- /dev/null +++ b/packages/ui/src/lib/mobile-history.ts @@ -0,0 +1,14 @@ +/** A browser Back dismisses the top mobile panel before leaving the conversation. */ +export function mobileOverlay(close: () => void): () => void { + if (!window.matchMedia('(max-width: 720px)').matches) return () => {}; + const id = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join(''); + history.pushState({ ...history.state, boiteOverlay: id }, ''); + const back = () => { + if (history.state?.boiteOverlay !== id) close(); + }; + window.addEventListener('popstate', back); + return () => { + window.removeEventListener('popstate', back); + if (history.state?.boiteOverlay === id) history.back(); + }; +} diff --git a/packages/ui/src/lib/notify.test.ts b/packages/ui/src/lib/notify.test.ts index fc1dd9d..a488677 100644 --- a/packages/ui/src/lib/notify.test.ts +++ b/packages/ui/src/lib/notify.test.ts @@ -63,6 +63,23 @@ describe('toastFor', () => { describe('sendNotification', () => { afterEach(() => setNotificationSender(null)); + test('a stale push flag still delivers through the worker with the push tag and raw thread id', async () => { + const showNotification = vi.fn().mockResolvedValue(undefined); + const browserNotification = vi.fn(function () {}); + Object.assign(browserNotification, { permission: 'granted' }); + vi.stubGlobal('Notification', browserNotification); + vi.stubGlobal('navigator', { serviceWorker: { getRegistration: vi.fn().mockResolvedValue({ showNotification }) } }); + localStorage.setItem('boite.web-push', 'on'); + try { + await sendNotification({ title: 'Done', body: 'Finished', threadId: '["machine","t-1"]', coreThreadId: 't-1', origin: location.origin }); + expect(showNotification).toHaveBeenCalledWith('Done', expect.objectContaining({ tag: 'thread-t-1', data: { threadId: 't-1' } })); + expect(browserNotification).not.toHaveBeenCalled(); + showNotification.mockRejectedValueOnce(new Error('worker unavailable')); + await sendNotification({ title: 'Done', body: 'Finished', threadId: 't-1', coreThreadId: 't-1', origin: location.origin }); + expect(browserNotification).toHaveBeenCalledOnce(); + } finally { localStorage.removeItem('boite.web-push'); vi.unstubAllGlobals(); } + }); + test('goes through the sender handed in, and swallows what it throws', async () => { const sent: Toast[] = []; setNotificationSender(async (toast) => { @@ -105,10 +122,16 @@ describe('sendNotification', () => { expect(opened).toEqual(['t-2']); expect(shown[0]?.closed).toBe(true); + localStorage.setItem('boite.web-push', 'on'); + await sendNotification({ title: 'Local', body: 'Done', threadId: 't-local', origin: location.origin }); + expect(shown).toHaveLength(2); + await sendNotification({ title: 'Remote', body: 'Done', threadId: 't-remote', origin: 'https://remote.test' }); + expect(shown).toHaveLength(3); + // Permission refused: nothing is shown, nothing throws. FakeNotification.permission = 'denied'; await sendNotification({ title: 'x', body: 'y', threadId: 't-3' }); - expect(shown).toHaveLength(1); + expect(shown).toHaveLength(3); } finally { stop(); vi.unstubAllGlobals(); diff --git a/packages/ui/src/lib/notify.ts b/packages/ui/src/lib/notify.ts index f8bbd75..52f049b 100644 --- a/packages/ui/src/lib/notify.ts +++ b/packages/ui/src/lib/notify.ts @@ -55,6 +55,10 @@ export function writeNotifications(enabled: boolean): void { export interface Toast { title: string; body: string; + /** The core that owns this event, for per-origin Web Push deduplication. */ + origin?: string; + /** Unqualified ID for the same-origin service worker's click handler. */ + coreThreadId?: string; /** The thread a click on the toast opens. */ threadId: string; } @@ -112,6 +116,21 @@ async function webSender(toast: Toast): Promise { if (typeof Notification === 'undefined') return; if (Notification.permission === 'default') await Notification.requestPermission(); if (Notification.permission !== 'granted') return; + // A subscription is not a delivery receipt. Keep the local path available, + // using the same worker and tag as push so the latest notice replaces it. + if (toast.origin === location.origin && toast.coreThreadId && navigator.serviceWorker) { + try { + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + await registration.showNotification(toast.title, { + body: toast.body, tag: `thread-${toast.coreThreadId}`, + icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', + data: { threadId: toast.coreThreadId } + }); + return; + } + } catch { /* A missing worker must not disable the browser fallback. */ } + } const notification = new Notification(toast.title, { body: toast.body, tag: toast.threadId }); notification.onclick = () => { window.focus(); diff --git a/packages/ui/src/lib/pwa.ts b/packages/ui/src/lib/pwa.ts new file mode 100644 index 0000000..e69d2ed --- /dev/null +++ b/packages/ui/src/lib/pwa.ts @@ -0,0 +1,24 @@ +import { strings } from './strings'; +interface InstallPrompt extends Event { prompt(): Promise<{ outcome: string }> } +let prompt: InstallPrompt | null = null; +export function listenForInstall(): () => void { + const before = (event: Event) => { event.preventDefault(); prompt = event as InstallPrompt; }; + window.addEventListener('beforeinstallprompt', before); + return () => { window.removeEventListener('beforeinstallprompt', before); prompt = null; }; +} +export async function installApp(): Promise { + if (!prompt) return false; + const current = prompt; + prompt = null; + return (await current.prompt()).outcome === 'accepted'; +} +export function installed(): boolean { + return window.matchMedia('(display-mode: standalone)').matches || (navigator as Navigator & { standalone?: boolean }).standalone === true; +} +export const PUSH_ENABLED_KEY = 'boite.web-push'; + +export async function worker(): Promise { + const registration = await navigator.serviceWorker.getRegistration('/'); + if (!registration?.active) throw new Error(strings.phone.preparing); + return registration; +} diff --git a/packages/ui/src/lib/service-worker.test.ts b/packages/ui/src/lib/service-worker.test.ts new file mode 100644 index 0000000..8ce9910 --- /dev/null +++ b/packages/ui/src/lib/service-worker.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { expect, test, vi } from 'vitest'; + +const source = readFileSync(resolve('public/sw.js'), 'utf8'); +function worker() { + const listeners = new Map void>(); + const notification = vi.fn().mockResolvedValue(undefined); + const openWindow = vi.fn().mockResolvedValue(undefined); + const navigate = vi.fn().mockResolvedValue(undefined); + const postMessage = vi.fn(); + const focus = vi.fn().mockResolvedValue(undefined); + const matchAll = vi.fn().mockResolvedValue([{ url: 'https://boite.test/', navigate, postMessage, focus }]); + const put = vi.fn().mockResolvedValue(undefined); + const cached = new Response('previous shell'); + const cache = { put, match: vi.fn().mockResolvedValue(cached), addAll: vi.fn().mockResolvedValue(undefined) }; + const caches = { open: vi.fn().mockResolvedValue(cache), keys: vi.fn().mockResolvedValue(['boite-ui-v1', 'another-app']), delete: vi.fn().mockResolvedValue(true) }; + const fetch = vi.fn().mockResolvedValue(new Response('current shell')); + runInNewContext(source, { URL, fetch, caches, self: { + addEventListener: (name: string, listener: (event: any) => void) => listeners.set(name, listener), + location: { origin: 'https://boite.test' }, registration: { showNotification: notification }, + clients: { matchAll, openWindow, claim: vi.fn().mockResolvedValue(undefined) }, skipWaiting: vi.fn().mockResolvedValue(undefined) + } }); + async function emit(name: string, input: Record = {}) { + let task: Promise | undefined; + listeners.get(name)!({ ...input, waitUntil: (p: Promise) => { task = p; }, respondWith: (p: Promise) => { task = p; } }); + return await task; + } + return { emit, notification, openWindow, navigate, postMessage, focus, matchAll, put, cache, caches, fetch }; +} + +test('a push displays a notification and clicking it opens only a same-origin thread URL', async () => { + const sw = worker(); + await sw.emit('push', { data: { json: () => ({ title: 'Review', body: 'Needs your answer', threadId: 'thread/&?test', tag: 'request-1' }) } }); + expect(sw.notification).toHaveBeenCalledWith('Review', expect.objectContaining({ body: 'Needs your answer', tag: 'thread-thread/&?test', data: { threadId: 'thread/&?test' } })); + const close = vi.fn(); + await sw.emit('notificationclick', { notification: { close, data: { threadId: 'https://other.test' } } }); + expect(sw.navigate).not.toHaveBeenCalled(); + expect(sw.postMessage).toHaveBeenCalledWith({ type: 'boite.open-thread', threadId: 'https://other.test' }); + expect(close).toHaveBeenCalled(); + expect(sw.focus).toHaveBeenCalled(); + sw.matchAll.mockResolvedValue([]); + await sw.emit('notificationclick', { notification: { close, data: { threadId: 'thread-2' } } }); + expect(sw.openWindow).toHaveBeenCalledWith('https://boite.test/?thread=thread-2'); +}); + +test('the current navigation replaces the offline shell and a failed core uses the cached shell', async () => { + const sw = worker(); + const request = { method: 'GET', mode: 'navigate', url: 'https://boite.test/?grant=one-time', headers: new Headers() }; + const current = await sw.emit('fetch', { request }) as Response; + expect(await current.text()).toBe('current shell'); + expect(sw.put).toHaveBeenCalledWith('/', expect.any(Response)); + sw.fetch.mockResolvedValue(new Response('proxy unavailable', { status: 503 })); + const offline = await sw.emit('fetch', { request }) as Response; + expect(await offline.text()).toBe('previous shell'); +}); + +test('installation caches entry scripts, styles and fonts before taking control', async () => { + const sw = worker(); + sw.fetch.mockResolvedValue(new Response('')); + await sw.emit('install'); + expect(sw.cache.addAll).toHaveBeenCalledExactlyOnceWith([ + '/assets/index-ab.js', '/assets/index-cd.css', '/manifest.webmanifest', '/icons/icon-192.png', '/fonts/Geist-Variable.woff2', '/fonts/GeistMono-Variable.woff2' + ]); + expect(sw.put).toHaveBeenCalledWith('/', expect.any(Response)); +}); + +test('activation keeps other apps caches and RPC is never handled', async () => { + const sw = worker(); + await sw.emit('activate'); + expect(sw.caches.delete).toHaveBeenCalledExactlyOnceWith('boite-ui-v1'); + await sw.emit('fetch', { request: { method: 'GET', mode: 'cors', url: 'https://boite.test/rpc', headers: new Headers() } }); + expect(sw.fetch).not.toHaveBeenCalled(); +}); diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index c1523df..a400047 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -177,6 +177,23 @@ const LIVE: ThreadStatusRank = { waiting: 0, running: 1, queued: 2, error: 3, id type ThreadStatusRank = Record; export class Store { + readonly readingPositions = new Map; anchor?: { id: string; offset: number } }>(); + #readingThreads = new Map(); + private rememberReadingThread(): void { + const thread = this.openThread; + if (!thread) return; + // Four recent timelines, with at most 4 MB of text/image data each. + // The active timeline remains unrestricted; old visits must not retain every image forever. + let bytes = 0; + for (const message of thread.messages) for (const part of message.parts) { + // Include nested tool inputs and documents, with conservative JSON overhead. + bytes += JSON.stringify(part).length * 2; + } + this.#readingThreads.delete(thread.id); + if (bytes <= 4 * 1024 * 1024 && thread.messages.length <= 2000) this.#readingThreads.set(thread.id, thread); + while (this.#readingThreads.size > 4) this.#readingThreads.delete(this.#readingThreads.keys().next().value!); + } + #pendingSends = new Map(); machineId = ''; visible = true; threadKey(id: string): string { return this.machineId ? JSON.stringify([this.machineId, id]) : id; } @@ -557,6 +574,9 @@ export class Store { attach(client: Client): void { this.detach(); + this.#readingThreads.clear(); + this.readingPositions.clear(); + this.#pendingSends.clear(); this.logins = {}; this.#loginChanges.clear(); this.#client = client; @@ -1200,6 +1220,7 @@ export class Store { startDraft(projectId?: ProjectId): void { const target = projectId ?? this.openProject?.id ?? this.projects[0]?.id; if (target === undefined) return; + this.rememberReadingThread(); void this.#unsubscribe(); this.openThread = null; this.draftChoice = null; @@ -1260,6 +1281,18 @@ export class Store { } const thread = await client.call('threads.get', { threadId }); if (!newest()) return; + this.rememberReadingThread(); + const cached = this.#readingThreads.get(threadId); + const freshIds = new Set(thread.messages.map(m => m.id)); + if (cached && cached.messages.some(m => freshIds.has(m.id))) { + const merged = new Map(cached.messages.map(m => [m.id, m])); + for (const message of thread.messages) merged.set(message.id, message); + thread.messages = [...merged.values()].sort((a, b) => a.createdAt - b.createdAt); + thread.messagesBefore = cached.messagesBefore; + } else { + this.#readingThreads.delete(threadId); + this.readingPositions.delete(threadId); + } this.draft = null; // The last page, pinned to the bottom; what is above it arrives on scroll. this.loadingOlder = false; @@ -1491,12 +1524,21 @@ export class Store { } // The key is left out when there is nothing to carry: a turn with no // image sends the params it always sent. + let pending = this.#pendingSends.get(threadId); + const selectionVersion = (this.openThread?.id === threadId ? this.openThread : this.threads.find((thread) => thread.id === threadId))?.selectionVersion ?? 0; + if (!pending || pending.selectionVersion !== selectionVersion || pending.prompt !== prompt || pending.attachments.length !== attachments.length || pending.attachments.some((a, i) => a.data !== attachments[i]?.data || a.mimeType !== attachments[i]?.mimeType || a.name !== attachments[i]?.name)) { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + pending = { id: Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''), prompt, attachments: [...attachments], selectionVersion }; + this.#pendingSends.set(threadId, pending); + } await client.call('turns.start', { threadId, prompt, - expectedSelectionVersion: (this.openThread?.id === threadId ? this.openThread : this.threads.find((thread) => thread.id === threadId))?.selectionVersion ?? 0, + clientRequestId: pending.id, + expectedSelectionVersion: selectionVersion, ...(attachments.length > 0 ? { attachments } : {}) }); + this.#pendingSends.delete(threadId); return true; } catch (error) { this.#fail(error); @@ -1942,7 +1984,11 @@ export class Store { }); if (!go) return; const title = this.threads.find((t) => t.id === threadId)?.title ?? strings.app.name; - void sendNotification(toastFor(kind, this.threadKey(threadId), title, detail)); + void sendNotification({ + ...toastFor(kind, this.threadKey(threadId), title, detail), + coreThreadId: threadId, + origin: this.endpointUrl ? new URL(this.endpointUrl).origin : undefined + }); } /** The switch of the Background card; the platform prompt comes with the first turn-on. */ diff --git a/packages/ui/src/lib/store.test.ts b/packages/ui/src/lib/store.test.ts index b520172..60b667d 100644 --- a/packages/ui/src/lib/store.test.ts +++ b/packages/ui/src/lib/store.test.ts @@ -36,6 +36,48 @@ async function ready(): Promise<{ store: Store; client: FakeClient }> { return { store, client }; } +test.each([false, true])('a lost start response reuses its request id unless selection changes: %s', async (changeSelection) => { + const { store, client } = await ready(); + const original = client.call.bind(client); + const requests: string[] = []; + let loseResponse = true; + vi.spyOn(client, 'call').mockImplementation(async (method, params) => { + const result = await original(method, params); + if (method === 'turns.start') { + requests.push((params as { clientRequestId: string }).clientRequestId); + if (loseResponse) { loseResponse = false; throw new Error('connection lost'); } + } + return result; + }); + try { + const thread = store.threads.find(thread => thread.status === 'idle')!; + await store.open(thread.id); + expect(await store.send('Retry this prompt')).toBe(false); + if (changeSelection) { + await client.settled(); + expect(await store.update(thread.id, { effort: 'low' })).toBe(true); + } + expect(await store.send('Retry this prompt')).toBe(true); + expect(requests).toHaveLength(2); + expect(requests[0]).toBeTruthy(); + expect(requests[1] === requests[0]).toBe(!changeSelection); + } finally { store.detach(); client.close(); } +}); + +test.each(['input', 'inputText', 'output', 'documents'])('reading cache excludes oversized tool %s', async (field) => { + const { store, client } = await ready(); + try { + await store.open('t-bench'); + const large = 'x'.repeat(2 * 1024 * 1024 + 1); + const part: any = { type: 'tool', toolId: 'large', name: 'read', input: {}, output: null, status: 'done' }; + part[field] = field === 'documents' ? [{ kind: 'markdown', text: large }] : field === 'input' ? { nested: { text: large } } : large; + store.openThread!.messages.unshift({ id: 'cached-only', threadId: 't-bench', turnId: 'old', role: 'assistant', parts: [part], state: 'complete', createdAt: 0 }); + await store.open('t-scheduler'); + await store.open('t-bench'); + expect(store.openThread!.messages.some(message => message.id === 'cached-only')).toBe(false); + } finally { store.detach(); client.close(); } +}); + describe('Store', () => { beforeEach(() => { window.localStorage.clear(); @@ -109,7 +151,7 @@ describe('Store', () => { await client.call('turns.start', { threadId: 't-trace', prompt: 'quietly' }); await client.settled(); - expect(sent).toEqual([{ title: 'Finish the trace tab', body: 'Done', threadId: 't-trace' }]); + expect(sent).toEqual([{ title: 'Finish the trace tab', body: 'Done', threadId: 't-trace', coreThreadId: 't-trace' }]); await store.send('in front of me'); await client.settled(); diff --git a/packages/ui/src/lib/strings.ts b/packages/ui/src/lib/strings.ts index abf4789..f8f027b 100644 --- a/packages/ui/src/lib/strings.ts +++ b/packages/ui/src/lib/strings.ts @@ -1,5 +1,27 @@ /** Every user-facing string of the UI. No literal UI text lives anywhere else. */ export const strings = { + phone: { + preparing: 'The app is still preparing offline files. Reload and try again.', + settingsOffline: 'Settings have not been downloaded yet. Reconnect and open Settings again.', + heading: 'Phone app', publicUrl: 'Public HTTPS address', urlPlaceholder: 'https://boite.example.com', + publicUrlHint: 'Use the HTTPS origin configured on your reverse proxy. New pairing links use this address. The proxy must forward the page and /rpc to this core.', + install: 'Install Boite', installed: 'Boite is running as an installed app.', + installHint: 'On iPhone, open Share in Safari, then Add to Home Screen. On Android, use Install app or Add to Home screen in the browser menu.', + httpsRequired: 'This connection uses HTTP. Open Boite through HTTPS to enable offline files and notifications.', + ownOrigin: 'Open this machine’s own pairing link to enable notifications for it.', + pairFirst: 'Open a device pairing link before enabling notifications.', + unsupported: 'This browser does not support push here. On iPhone, add Boite to the Home Screen and open it from its icon.', + pushHint: 'Receive a notification when an agent finishes, fails or needs your answer, including while Boite is closed.', + enable: 'Enable notifications', disable: 'Disable notifications', test: 'Send test notification', + enabled: 'Notifications enabled for this device.', testSent: 'The push service accepted the test notification.', + denied: 'Notifications were not allowed. You can change this in the browser or device settings.', + subscriptionFailed: 'The browser did not return a complete push subscription.' + }, + mobile: { + navigation: 'Navigation', threads: 'Conversations', activity: 'Activity', project: 'Choose project', + activityHint: 'Running agents and requests waiting for you.', threadsHint: 'Your conversations across machines.', + search: 'Find a conversation', unread: 'Unread', noActivity: 'No agents need your attention.', noThreads: 'No matching conversations.' + }, machines: { heading: 'Machines', local: 'This PC', projects: 'Projects', recent: 'Recent', dynamic: 'All machines', recentHint: 'Most recent user message first', diff --git a/packages/ui/src/lib/viewport.test.ts b/packages/ui/src/lib/viewport.test.ts new file mode 100644 index 0000000..81eb0a9 --- /dev/null +++ b/packages/ui/src/lib/viewport.test.ts @@ -0,0 +1,22 @@ +import { expect, test, vi } from 'vitest'; +import { startViewport } from './viewport'; + +test('keyboard resize and scroll update the visible area and cleanup removes listeners', () => { + const viewport = new EventTarget() as EventTarget & { height: number; offsetTop: number }; + viewport.height = 800; + viewport.offsetTop = 0; + vi.stubGlobal('visualViewport', viewport); + vi.stubGlobal('innerHeight', 800); + vi.stubGlobal('matchMedia', () => Object.assign(new EventTarget(), { matches: true })); + const stop = startViewport(); + viewport.height = 450; + viewport.offsetTop = 20; + viewport.dispatchEvent(new Event('resize')); + expect(document.documentElement.style.getPropertyValue('--app-height')).toBe('450px'); + expect(document.documentElement.style.getPropertyValue('--app-top')).toBe('20px'); + expect(document.documentElement.dataset.keyboard).toBe('open'); + stop(); + viewport.dispatchEvent(new Event('scroll')); + expect(document.documentElement.style.getPropertyValue('--app-height')).toBe(''); + vi.unstubAllGlobals(); +}); diff --git a/packages/ui/src/lib/viewport.ts b/packages/ui/src/lib/viewport.ts new file mode 100644 index 0000000..4ebf693 --- /dev/null +++ b/packages/ui/src/lib/viewport.ts @@ -0,0 +1,32 @@ +/** Keep the app and its overlays inside the part of the screen above the keyboard. */ +export function startViewport(): () => void { + const root = document.documentElement; + const viewport = window.visualViewport; + const mobile = window.matchMedia('(max-width: 720px)'); + const update = () => { + if (!mobile.matches) { + root.style.removeProperty('--app-height'); + root.style.removeProperty('--app-top'); + delete root.dataset.keyboard; + return; + } + const height = viewport?.height ?? window.innerHeight; + root.style.setProperty('--app-height', `${height}px`); + root.style.setProperty('--app-top', `${viewport?.offsetTop ?? 0}px`); + root.dataset.keyboard = window.innerHeight - height > 120 ? 'open' : 'closed'; + }; + update(); + viewport?.addEventListener('resize', update); + viewport?.addEventListener('scroll', update); + window.addEventListener('resize', update); + mobile.addEventListener('change', update); + return () => { + viewport?.removeEventListener('resize', update); + viewport?.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + mobile.removeEventListener('change', update); + root.style.removeProperty('--app-height'); + root.style.removeProperty('--app-top'); + delete root.dataset.keyboard; + }; +} diff --git a/packages/ui/test-setup.ts b/packages/ui/test-setup.ts index edda0ce..b674515 100644 --- a/packages/ui/test-setup.ts +++ b/packages/ui/test-setup.ts @@ -1,4 +1,7 @@ import { vi } from 'vitest'; +Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: (media: string) => Object.assign(new EventTarget(), { + media, matches: false, onchange: null, addListener() {}, removeListener() {} +}) }); // jsdom has no layout. Browser tests cover measured sizes and scrolling. vi.stubGlobal('ResizeObserver', class { diff --git a/tests/e2e/lib/cdp.ts b/tests/e2e/lib/cdp.ts index e92f870..de95e17 100644 --- a/tests/e2e/lib/cdp.ts +++ b/tests/e2e/lib/cdp.ts @@ -81,6 +81,7 @@ export class BrowserPage { #pending = new Map(); #closed = false; #pageErrors: string[] = []; + errors(): string[] { return [...this.#pageErrors]; } private constructor(socket: WebSocket, pid: number | null, userDataDir: string | null) { this.#socket = socket; diff --git a/tests/e2e/mobile.test.ts b/tests/e2e/mobile.test.ts new file mode 100644 index 0000000..0f54981 --- /dev/null +++ b/tests/e2e/mobile.test.ts @@ -0,0 +1,102 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { BrowserPage, freePort } from './lib/cdp.ts'; + +const uiRequire = createRequire(join(import.meta.dir, '../../packages/ui/package.json')); +const { createServer } = await import(uiRequire.resolve('vite')); +let server: { listen(): Promise; close(): Promise }; +let page: BrowserPage; +async function capture(name: string) { + await page.evaluate(`Promise.all([document.fonts.ready, ...document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {}))])`); + await page.screenshot(join(import.meta.dir, '.artifacts', name)); +} +beforeAll(async () => { + const port = await freePort(); + server = await createServer({ root: join(import.meta.dir, '../../packages/ui'), server: { host: '127.0.0.1', port, strictPort: true }, clearScreen: false }); + await server.listen(); + page = await BrowserPage.launch({ url: `http://127.0.0.1:${port}/?fake=1&machines=1` }); + await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); + await page.waitFor(`document.querySelector('[data-testid=mobile-tabs]')`); +}, 30_000); +afterAll(async () => { await page?.close(); await server?.close(); }, 15_000); + +test('phone navigates conversations, activity and settings without a sidebar', async () => { + await page.click('[data-testid=mobile-tabs] button:nth-child(1)'); + await page.waitFor(`document.querySelector('[data-testid=mobile-list] .thread')`); + const tabs = await page.evaluate<{ bottom: number; height: number }>(`(() => { const r = document.querySelector('[data-testid=mobile-tabs]').getBoundingClientRect(); return {bottom:r.bottom,height:r.height}; })()`); + expect(tabs.bottom).toBeLessThanOrEqual(844); + expect(tabs.height).toBeGreaterThanOrEqual(44); + expect(await page.evaluate(`document.documentElement.scrollWidth <= innerWidth`)).toBe(true); + await capture('mobile-conversations.png'); + await page.click('[data-testid=mobile-list] .thread'); + await page.waitFor(`!document.querySelector('[data-testid=mobile-list]') && document.querySelector('[data-testid=chat]')`); + await capture('mobile-chat.png'); + expect(await page.evaluate(`document.querySelector('[data-testid=titlebar]').getBoundingClientRect().bottom <= document.querySelector('[data-testid=timeline]').getBoundingClientRect().top`)).toBe(true); + await page.click('[data-testid=mobile-tabs] button:nth-child(2)'); + await page.waitFor(`document.querySelector('[data-testid=mobile-list] h1')?.textContent === 'Activity'`); + await capture('mobile-activity.png'); + await page.click('[data-testid=mobile-tabs] button:nth-child(3)'); + await page.waitFor(`document.querySelector('[data-testid=settings]')`); + await page.click('[data-testid=mobile-new]'); + await page.waitFor(`document.querySelector('[data-testid=composer]') && !document.querySelector('[data-testid=settings]')`); + expect(page.errors()).toEqual([]); +}, 30_000); + +test('draft survives navigation and the light phone layout fits landscape', async () => { + await page.evaluate(`(() => { const t = document.querySelector('[data-testid=composer-input]'); t.value = 'Keep this draft'; t.dispatchEvent(new Event('input', {bubbles:true})); })()`); + await page.click('[data-testid=mobile-tabs] button:nth-child(1)'); + await page.click('[data-testid=mobile-header] [data-testid=mobile-new]'); + expect(await page.evaluate(`document.querySelector('[data-testid=composer-input]').value`)).toBe('Keep this draft'); + await page.evaluate(`document.documentElement.dataset.theme = 'light'`); + await page.send('Emulation.setDeviceMetricsOverride', { width: 700, height: 390, deviceScaleFactor: 1, mobile: true }); + await capture('mobile-landscape.png'); + expect(await page.evaluate(`document.documentElement.scrollWidth <= innerWidth`)).toBe(true); + const bounds = await page.evaluate(`(() => { const r = document.querySelector('[data-testid=composer-input]').getBoundingClientRect(); return {top:r.top,bottom:r.bottom}; })()`); + expect(bounds.top).toBeGreaterThanOrEqual(0); + expect(bounds.bottom).toBeLessThanOrEqual(390); +}, 15_000); + +test('model sheets stay on screen and browser Back closes the sheet without losing the draft', async () => { + await page.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); + await page.click('[data-testid=mobile-new]'); + await page.waitFor(`document.querySelector('[data-testid=composer-input]')`); + await page.evaluate(`(() => { const t = document.querySelector('[data-testid=composer-input]'); t.value = 'Keep this draft'; t.dispatchEvent(new Event('input', {bubbles:true})); })()`); + await page.click('[data-testid=composer-picker]'); + await page.waitFor(`document.querySelector('[data-testid=composer-picker-menu]')`); + await capture('mobile-model-sheet.png'); + const bounds = await page.evaluate(`(() => { const r=document.querySelector('[data-testid=composer-picker-menu]').getBoundingClientRect(); return {left:r.left,right:r.right,top:r.top,bottom:r.bottom}; })()`); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(390); + expect(bounds.top).toBeGreaterThanOrEqual(0); + expect(bounds.bottom).toBeLessThanOrEqual(844); + await page.evaluate('history.back()'); + await page.waitFor(`!document.querySelector('[data-testid=composer-picker-menu]')`); + expect(await page.evaluate(`document.querySelector('[data-testid=composer-input]').value`)).toBe('Keep this draft'); + await page.click('[data-testid=mobile-tabs] button:nth-child(3)'); + await page.waitFor(`document.querySelector('[data-testid=phone-settings]')`); + await capture('mobile-installation.png'); +}, 15_000); + +test('returning to a long conversation preserves the reading position', async () => { + const origin = await page.evaluate('location.origin'); + await page.navigate(`${origin}/?fake=1&long=1`); + await page.waitFor(`document.querySelector('[data-testid=thread-title]')?.textContent.includes('Four hundred')`); + await page.evaluate(`(() => { const t=document.querySelector('[data-testid=timeline]'); t.scrollTop = t.scrollHeight - t.clientHeight - 1200; t.dispatchEvent(new Event('scroll')); })()`); + await capture('mobile-long-reading.png'); + const previous = await page.evaluate(`document.querySelector('[data-testid=timeline]').scrollTop`); + const visibleAnchor = `(() => { const t=document.querySelector('[data-testid=timeline]'); const top=t.getBoundingClientRect().top; const m=[...t.querySelectorAll('[data-mid]')].find(m=>m.getBoundingClientRect().bottom>top); return {id:m.dataset.mid,offset:m.getBoundingClientRect().top-top}; })()`; + const anchor = await page.evaluate<{ id: string; offset: number }>(visibleAnchor); + await page.click('[data-testid=mobile-tabs] button:nth-child(1)'); + await page.click('[data-testid=mobile-list] .thread:not([data-testid=mobile-thread-t-long])'); + await page.waitFor(`!document.querySelector('[data-testid=mobile-list]')`); + await page.click('[data-testid=mobile-tabs] button:nth-child(1)'); + await page.click('[data-testid=mobile-thread-t-long]'); + await page.waitFor(`!document.querySelector('[data-testid=mobile-list]')`); + await capture('mobile-long-restored.png'); + const restored = await page.evaluate(`document.querySelector('[data-testid=timeline]').scrollTop`); + expect(Math.abs(restored - previous)).toBeLessThan(60); + const restoredAnchor = await page.evaluate<{ id: string; offset: number }>(visibleAnchor); + expect(restoredAnchor.id).toBe(anchor.id); + expect(Math.abs(restoredAnchor.offset - anchor.offset)).toBeLessThan(10); +}, 15_000); diff --git a/tests/e2e/readability.test.ts b/tests/e2e/readability.test.ts index 8fb7c59..18ff98b 100644 --- a/tests/e2e/readability.test.ts +++ b/tests/e2e/readability.test.ts @@ -89,6 +89,20 @@ test('goal prompts and markers stay readable and recognized commands are accente await capture('readability-goal'); await page.send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); await capture('readability-chat-phone'); + await page.evaluate(`(() => { const input = document.querySelector('${id('composer-input')}'); input.value = '/goal ' + 'Check a long prompt that wraps over several lines. '.repeat(8); input.dispatchEvent(new Event('input',{bubbles:true})); })()`); + await page.waitFor(`Math.abs(document.querySelector('.input-mirror').getBoundingClientRect().width - document.querySelector('${id('composer-input')}').clientWidth) < 1`); + const phoneText = await page.evaluate<{ inputFont: string[]; mirrorFont: string[]; inputHeight: number; mirrorHeight: number }>(`(() => { + const input = document.querySelector('${id('composer-input')}'); + const mirror = document.querySelector('.input-mirror'); + const typography = element => { + const style = getComputedStyle(element); + return [style.fontFamily, style.fontSize, style.fontWeight, style.fontStyle, style.lineHeight, style.letterSpacing]; + }; + return { inputFont: typography(input), mirrorFont: typography(mirror), inputHeight: input.scrollHeight, mirrorHeight: mirror.scrollHeight }; + })()`); + expect(phoneText.mirrorFont).toEqual(phoneText.inputFont); + expect(Math.abs(phoneText.mirrorHeight - phoneText.inputHeight)).toBeLessThan(2); + await capture('readability-composer-phone'); await page.evaluate(`document.documentElement.dataset.theme = 'light'`); await capture('readability-chat-light'); expect(await page.evaluate('document.documentElement.scrollWidth <= innerWidth')).toBe(true); diff --git a/tests/e2e/ui.test.ts b/tests/e2e/ui.test.ts index 1afe0ca..b5fe562 100644 --- a/tests/e2e/ui.test.ts +++ b/tests/e2e/ui.test.ts @@ -30,7 +30,7 @@ const RETITLE_SCREENSHOT = join(import.meta.dir, '.artifacts', 'ui-retitle.png') const IMPORT_SCREENSHOT = join(import.meta.dir, '.artifacts', 'ui-import.png'); const EXPERIMENTS_SCREENSHOT = join(import.meta.dir, '.artifacts', 'ui-experiments.png'); /** The one name `public/sw.js` opens; every other cache is deleted on activate. */ -const UI_CACHE = 'boite-ui-v1'; +const UI_CACHE = 'boite-ui-v2'; /** What the echo provider's `[tool-stream]` directive types, one piece at a time. */ const STREAMED_TOOL_INPUT = '{"command":"echo streamed","description":"a streamed input"}'; @@ -675,11 +675,22 @@ test( const grantUrl = await mintPairing(core); const phone = await BrowserPage.launch({ url: grantUrl }); try { + await phone.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); await phone.waitFor(`document.querySelector('[data-testid=status-connection]')?.dataset.state === 'ready'`, 30_000); const stored = await phone.evaluate<{ url: string; token: string }>(`JSON.parse(localStorage.getItem('boite.core'))`); expect(stored.token).toHaveLength(64); expect(stored.token).not.toBe(core.token); expect(await phone.evaluate('location.search')).toBe(''); + await phone.waitFor(`document.querySelector('[data-testid=mobile-tabs]')`); + await phone.click('[data-testid=mobile-tabs] button:nth-child(3)'); + await phone.waitFor(`document.querySelector('[data-testid=phone-settings]')`); + expect(await phone.evaluate(`document.querySelector('[data-testid=phone-public-url]') === null`)).toBe(true); + await phone.waitFor(`navigator.serviceWorker.controller !== null`, RECONNECT_TIMEOUT_MS); + await phone.waitFor(`Array.from(document.querySelectorAll('[data-testid=phone-settings] button')).some(button => button.textContent.includes('Enable notifications') && !button.disabled)`); + await phone.evaluate(`Promise.all([document.fonts.ready, ...document.getAnimations().filter(animation => animation.effect?.getTiming().iterations !== Infinity).map(animation => animation.finished.catch(() => {}))])`); + await phone.screenshot(join(import.meta.dir, '.artifacts', 'mobile-paired-settings.png')); + await phone.click('[data-testid=mobile-tabs] button:nth-child(1)'); + await phone.waitFor(`document.querySelector('[data-testid=status-connection]')?.dataset.state === 'ready'`); // Same link again: refused, and the page says so instead of retrying forever. const again = await BrowserPage.launch({ url: grantUrl });