From af952b28464c33c39887cc595b1eee9f27d43c91 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:21:57 +0200 Subject: [PATCH 1/6] feat: add mobile PWA and Web Push --- bun.lock | 30 ++++ docs/development.md | 21 +++ docs/phone.md | 102 +++++++++-- docs/server.md | 3 + packages/contracts/src/index.ts | 9 +- packages/core/package.json | 4 +- packages/core/src/access.ts | 5 + packages/core/src/core.ts | 4 + packages/core/src/journal.ts | 14 +- packages/core/src/main.ts | 8 + packages/core/src/modules.ts | 2 + packages/core/src/push.ts | 169 ++++++++++++++++++ packages/core/src/server.ts | 2 +- packages/core/src/sessions.ts | 2 +- packages/core/src/settings.ts | 8 + packages/core/src/threads.ts | 17 +- packages/core/test/model-switch.test.ts | 2 +- packages/core/test/push.test.ts | 107 +++++++++++ packages/core/test/turn-retry.test.ts | 18 ++ packages/ui/index.html | 3 + packages/ui/public/sw.js | 52 +++++- packages/ui/src/App.svelte | 69 ++++++- packages/ui/src/app.css | 7 + packages/ui/src/components/Composer.svelte | 1 + packages/ui/src/components/Composer.test.ts | 2 +- .../ui/src/components/EffortSlider.svelte | 2 + .../ui/src/components/GeneralSettings.svelte | 2 + packages/ui/src/components/Menu.svelte | 2 + packages/ui/src/components/MessageList.svelte | 72 +++++++- .../ui/src/components/MobileNavigation.svelte | 107 +++++++++++ packages/ui/src/components/ModelPicker.svelte | 2 +- .../ui/src/components/PhoneSettings.svelte | 113 ++++++++++++ .../ui/src/components/SettingsShell.svelte | 12 +- packages/ui/src/lib/client.test.ts | 23 +++ packages/ui/src/lib/client.ts | 38 +++- packages/ui/src/lib/fake-client.ts | 20 ++- packages/ui/src/lib/floating.ts | 37 +++- packages/ui/src/lib/mobile-history.ts | 14 ++ packages/ui/src/lib/notify.test.ts | 8 +- packages/ui/src/lib/notify.ts | 5 + packages/ui/src/lib/pwa.ts | 24 +++ packages/ui/src/lib/service-worker.test.ts | 75 ++++++++ packages/ui/src/lib/store.svelte.ts | 46 ++++- packages/ui/src/lib/store.test.ts | 24 +++ packages/ui/src/lib/strings.ts | 22 +++ packages/ui/src/lib/viewport.test.ts | 22 +++ packages/ui/src/lib/viewport.ts | 32 ++++ packages/ui/test-setup.ts | 3 + tests/e2e/lib/cdp.ts | 1 + tests/e2e/mobile.test.ts | 98 ++++++++++ tests/e2e/ui.test.ts | 13 +- 51 files changed, 1418 insertions(+), 60 deletions(-) create mode 100644 packages/core/src/push.ts create mode 100644 packages/core/test/push.test.ts create mode 100644 packages/core/test/turn-retry.test.ts create mode 100644 packages/ui/src/components/MobileNavigation.svelte create mode 100644 packages/ui/src/components/PhoneSettings.svelte create mode 100644 packages/ui/src/lib/mobile-history.ts create mode 100644 packages/ui/src/lib/pwa.ts create mode 100644 packages/ui/src/lib/service-worker.test.ts create mode 100644 packages/ui/src/lib/viewport.test.ts create mode 100644 packages/ui/src/lib/viewport.ts create mode 100644 tests/e2e/mobile.test.ts 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 1e7ead2..a8478e5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -284,6 +284,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/phone.md b/docs/phone.md index f2c4287..5122be5 100644 --- a/docs/phone.md +++ b/docs/phone.md @@ -94,11 +94,59 @@ 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. + +## 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 +157,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 +184,34 @@ 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. + +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 +222,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 90c64b2..2ae6b79 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. ### Image verification diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 253754e..fe72756 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -632,6 +632,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. */ @@ -935,6 +937,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 }; @@ -1097,7 +1104,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 a07d00a..d74a6f9 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}`); } @@ -625,6 +629,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 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 34a898f..c84a014 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 { relative, resolve } from 'node:path'; import { ATTACHMENTS_PER_TURN, ATTACHMENT_MAX_BYTES, IMAGE_MIME_TYPES, MESSAGE_PAGE, MESSAGE_PAGE_MAX } from '@boite/contracts'; import type { @@ -570,8 +571,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'): Turn { + startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact', 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)) { @@ -625,6 +637,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); }); this.core.bus.emit('message.started', message); this.core.bus.emit('message.completed', { threadId, messageId: message.id, state: 'complete' }); @@ -1283,7 +1296,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, params.clientRequestId), ); core.router.register('turns.stop', (params) => { core.activity.pauseAll(params.threadId); diff --git a/packages/core/test/model-switch.test.ts b/packages/core/test/model-switch.test.ts index f26017c..eebd64d 100644 --- a/packages/core/test/model-switch.test.ts +++ b/packages/core/test/model-switch.test.ts @@ -129,7 +129,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..0d2186a 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: 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 ee874f3..c6f2f2c 100644 --- a/packages/ui/src/App.svelte +++ b/packages/ui/src/App.svelte @@ -9,7 +9,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'; @@ -23,11 +22,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`. @@ -90,7 +138,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,11 +290,12 @@
+ {#if !inShell && store.booted}{/if} {#if inShell} {/if} -
+
{#if !store.booted}

{strings.app.loading}

{:else if store.connection === 'closed' && !store.core} @@ -252,10 +308,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} @@ -480,6 +536,9 @@ } @media (max-width: 720px) { + .app:not(.shell) { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr); height: var(--app-height, 100dvh); top: var(--app-top, 0px); } + .app:not(.shell) .body { grid-row: 2; 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 0a5cc93..0ce2a57 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -79,6 +79,7 @@ --control: 30px; --control-sm: 26px; --input: 34px; + --touch-target: 44px; --dur-1: 90ms; --dur-2: 150ms; @@ -759,6 +760,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 da667cb..a11d384 100644 --- a/packages/ui/src/components/Composer.svelte +++ b/packages/ui/src/components/Composer.svelte @@ -1000,6 +1000,7 @@ } @media (max-width: 720px) { + textarea { 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 a85b528..e091cdc 100644 --- a/packages/ui/src/components/Composer.test.ts +++ b/packages/ui/src/components/Composer.test.ts @@ -750,7 +750,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 4c9cfb9..420acf3 100644 --- a/packages/ui/src/components/Menu.svelte +++ b/packages/ui/src/components/Menu.svelte @@ -1,6 +1,7 @@
-
+ + +
{#if store.loadingOlder} diff --git a/packages/ui/src/components/MobileNavigation.svelte b/packages/ui/src/components/MobileNavigation.svelte new file mode 100644 index 0000000..3e49ccb --- /dev/null +++ b/packages/ui/src/components/MobileNavigation.svelte @@ -0,0 +1,107 @@ + + +
+ {#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 1aecc51..b2e453a 100644 --- a/packages/ui/src/components/ModelPicker.svelte +++ b/packages/ui/src/components/ModelPicker.svelte @@ -378,7 +378,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 5519418..badcff4 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', @@ -393,6 +394,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; @@ -632,6 +634,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)) { @@ -997,10 +1005,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..078c11c 100644 --- a/packages/ui/src/lib/notify.test.ts +++ b/packages/ui/src/lib/notify.test.ts @@ -105,10 +105,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(1); + await sendNotification({ title: 'Remote', body: 'Done', threadId: 't-remote', origin: 'https://remote.test' }); + expect(shown).toHaveLength(2); + // 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(2); } finally { stop(); vi.unstubAllGlobals(); diff --git a/packages/ui/src/lib/notify.ts b/packages/ui/src/lib/notify.ts index f8bbd75..12cd31d 100644 --- a/packages/ui/src/lib/notify.ts +++ b/packages/ui/src/lib/notify.ts @@ -14,6 +14,7 @@ */ import { strings } from './strings'; +import { PUSH_ENABLED_KEY } from './pwa'; export const NOTIFICATIONS_STORAGE_KEY = 'boite.notifications'; @@ -55,6 +56,8 @@ 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; /** The thread a click on the toast opens. */ threadId: string; } @@ -109,6 +112,8 @@ async function shellSender(toast: Toast): Promise { } async function webSender(toast: Toast): Promise { + // This origin receives these events through the worker, including while closed. + if (toast.origin === location.origin && localStorage.getItem(PUSH_ENABLED_KEY) === 'on') return; if (typeof Notification === 'undefined') return; if (Notification.permission === 'default') await Notification.requestPermission(); if (Notification.permission !== 'granted') return; 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..147d3ca --- /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: 'request-1', 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 fb98d6a..e12d17f 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -175,6 +175,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) { + if ('data' in part && typeof part.data === 'string') bytes += part.data.length * 2; + if ('text' in part && typeof part.text === 'string') bytes += part.text.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; } @@ -545,6 +562,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; @@ -1187,6 +1207,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; @@ -1247,6 +1268,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; @@ -1457,12 +1490,20 @@ 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); + if (!pending || 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] }; + this.#pendingSends.set(threadId, pending); + } await client.call('turns.start', { threadId, prompt, + clientRequestId: pending.id, expectedSelectionVersion: (this.openThread?.id === threadId ? this.openThread : this.threads.find((thread) => thread.id === threadId))?.selectionVersion ?? 0, ...(attachments.length > 0 ? { attachments } : {}) }); + this.#pendingSends.delete(threadId); return true; } catch (error) { this.#fail(error); @@ -1908,7 +1949,10 @@ 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), + 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..adaf761 100644 --- a/packages/ui/src/lib/store.test.ts +++ b/packages/ui/src/lib/store.test.ts @@ -36,6 +36,30 @@ async function ready(): Promise<{ store: Store; client: FakeClient }> { return { store, client }; } +test('a lost start response retains its request id until the retry is acknowledged', async () => { + 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); + expect(await store.send('Retry this prompt')).toBe(true); + expect(requests).toHaveLength(2); + expect(requests[0]).toBeTruthy(); + expect(requests[1]).toBe(requests[0]); + } finally { store.detach(); client.close(); } +}); + describe('Store', () => { beforeEach(() => { window.localStorage.clear(); diff --git a/packages/ui/src/lib/strings.ts b/packages/ui/src/lib/strings.ts index 37e243d..9f42617 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: 'My computer', 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 29dc5bc..080de4a 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 918a045..ea28898 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..e9e2bc0 --- /dev/null +++ b/tests/e2e/mobile.test.ts @@ -0,0 +1,98 @@ +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'); + 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=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/ui.test.ts b/tests/e2e/ui.test.ts index 3c85d0f..34800b8 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"}'; @@ -674,11 +674,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 }); From 0f754bad69b1c6fe5c980229f45b58b76823c009 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:34:55 +0200 Subject: [PATCH 2/6] docs(phone): add mobile interface captures --- docs/images/phone/chat.png | Bin 0 -> 52101 bytes docs/images/phone/conversations.png | Bin 0 -> 51101 bytes docs/images/phone/model-picker.png | Bin 0 -> 53013 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/phone/chat.png create mode 100644 docs/images/phone/conversations.png create mode 100644 docs/images/phone/model-picker.png diff --git a/docs/images/phone/chat.png b/docs/images/phone/chat.png new file mode 100644 index 0000000000000000000000000000000000000000..8aab9f94c7c82567df5685111c1d466e56bdc3f9 GIT binary patch literal 52101 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�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 AF8}}l literal 0 HcmV?d00001 diff --git a/docs/images/phone/conversations.png b/docs/images/phone/conversations.png new file mode 100644 index 0000000000000000000000000000000000000000..91f9c28a81126c7a64c988dc9860aeab3fd07f98 GIT binary patch literal 51101 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 literal 0 HcmV?d00001 From e609a9c1e5139f0991ef70d0986134398b889bc8 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:05:20 +0200 Subject: [PATCH 3/6] fix(phone): address review and preserve streaming snapshots --- packages/core/src/journal.ts | 5 ++++- packages/core/test/core.test.ts | 4 +++- packages/core/test/journal.test.ts | 7 +++++++ packages/ui/src/components/MobileNavigation.svelte | 2 +- packages/ui/src/lib/store.svelte.ts | 4 ++-- packages/ui/src/lib/store.test.ts | 14 ++++++++++++++ tests/e2e/mobile.test.ts | 3 +++ 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/core/src/journal.ts b/packages/core/src/journal.ts index d74a6f9..1e21b65 100644 --- a/packages/core/src/journal.ts +++ b/packages/core/src/journal.ts @@ -565,7 +565,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); @@ -725,6 +725,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/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/ui/src/components/MobileNavigation.svelte b/packages/ui/src/components/MobileNavigation.svelte index 3e49ccb..dc8617e 100644 --- a/packages/ui/src/components/MobileNavigation.svelte +++ b/packages/ui/src/components/MobileNavigation.svelte @@ -63,7 +63,7 @@ {:else}

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

diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index e12d17f..e054522 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -184,8 +184,8 @@ export class Store { // 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) { - if ('data' in part && typeof part.data === 'string') bytes += part.data.length * 2; - if ('text' in part && typeof part.text === 'string') bytes += part.text.length * 2; + // 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); diff --git a/packages/ui/src/lib/store.test.ts b/packages/ui/src/lib/store.test.ts index adaf761..88ca42e 100644 --- a/packages/ui/src/lib/store.test.ts +++ b/packages/ui/src/lib/store.test.ts @@ -60,6 +60,20 @@ test('a lost start response retains its request id until the retry is acknowledg } 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(); diff --git a/tests/e2e/mobile.test.ts b/tests/e2e/mobile.test.ts index e9e2bc0..c1eff42 100644 --- a/tests/e2e/mobile.test.ts +++ b/tests/e2e/mobile.test.ts @@ -58,6 +58,9 @@ test('draft survives navigation and the light phone layout fits landscape', asyn 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'); From 15619525dab678aa0cb8ba222d068a6b686badbf Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:22:56 +0200 Subject: [PATCH 4/6] fix(phone): preserve notification fallback and retry selection --- docs/phone.md | 7 +++++++ packages/ui/public/sw.js | 2 +- packages/ui/src/lib/notify.test.ts | 23 +++++++++++++++++++--- packages/ui/src/lib/notify.ts | 20 ++++++++++++++++--- packages/ui/src/lib/service-worker.test.ts | 2 +- packages/ui/src/lib/store.svelte.ts | 10 ++++++---- packages/ui/src/lib/store.test.ts | 10 +++++++--- 7 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/phone.md b/docs/phone.md index 5122be5..710efd0 100644 --- a/docs/phone.md +++ b/docs/phone.md @@ -115,6 +115,8 @@ 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 @@ -208,6 +210,11 @@ 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 diff --git a/packages/ui/public/sw.js b/packages/ui/public/sw.js index 0d2186a..1b3d332 100644 --- a/packages/ui/public/sw.js +++ b/packages/ui/public/sw.js @@ -102,7 +102,7 @@ self.addEventListener('push', (event) => { 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: typeof payload.tag === 'string' ? payload.tag : 'boite-update', + tag: threadId ? `thread-${threadId}` : typeof payload.tag === 'string' ? payload.tag : 'boite-update', data: { threadId } }); })()); diff --git a/packages/ui/src/lib/notify.test.ts b/packages/ui/src/lib/notify.test.ts index 078c11c..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) => { @@ -107,14 +124,14 @@ describe('sendNotification', () => { localStorage.setItem('boite.web-push', 'on'); await sendNotification({ title: 'Local', body: 'Done', threadId: 't-local', origin: location.origin }); - expect(shown).toHaveLength(1); - await sendNotification({ title: 'Remote', body: 'Done', threadId: 't-remote', origin: 'https://remote.test' }); 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(2); + 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 12cd31d..52f049b 100644 --- a/packages/ui/src/lib/notify.ts +++ b/packages/ui/src/lib/notify.ts @@ -14,7 +14,6 @@ */ import { strings } from './strings'; -import { PUSH_ENABLED_KEY } from './pwa'; export const NOTIFICATIONS_STORAGE_KEY = 'boite.notifications'; @@ -58,6 +57,8 @@ export interface Toast { 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,11 +113,24 @@ async function shellSender(toast: Toast): Promise { } async function webSender(toast: Toast): Promise { - // This origin receives these events through the worker, including while closed. - if (toast.origin === location.origin && localStorage.getItem(PUSH_ENABLED_KEY) === 'on') return; 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/service-worker.test.ts b/packages/ui/src/lib/service-worker.test.ts index 147d3ca..8ce9910 100644 --- a/packages/ui/src/lib/service-worker.test.ts +++ b/packages/ui/src/lib/service-worker.test.ts @@ -33,7 +33,7 @@ function worker() { 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: 'request-1', data: { threadId: 'thread/&?test' } })); + 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(); diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index e054522..a2e64cc 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -191,7 +191,7 @@ export class Store { 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(); + #pendingSends = new Map(); machineId = ''; visible = true; threadKey(id: string): string { return this.machineId ? JSON.stringify([this.machineId, id]) : id; } @@ -1491,16 +1491,17 @@ 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); - if (!pending || 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 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] }; + 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, clientRequestId: pending.id, - expectedSelectionVersion: (this.openThread?.id === threadId ? this.openThread : this.threads.find((thread) => thread.id === threadId))?.selectionVersion ?? 0, + expectedSelectionVersion: selectionVersion, ...(attachments.length > 0 ? { attachments } : {}) }); this.#pendingSends.delete(threadId); @@ -1951,6 +1952,7 @@ export class Store { const title = this.threads.find((t) => t.id === threadId)?.title ?? strings.app.name; void sendNotification({ ...toastFor(kind, this.threadKey(threadId), title, detail), + coreThreadId: threadId, origin: this.endpointUrl ? new URL(this.endpointUrl).origin : undefined }); } diff --git a/packages/ui/src/lib/store.test.ts b/packages/ui/src/lib/store.test.ts index 88ca42e..60b667d 100644 --- a/packages/ui/src/lib/store.test.ts +++ b/packages/ui/src/lib/store.test.ts @@ -36,7 +36,7 @@ async function ready(): Promise<{ store: Store; client: FakeClient }> { return { store, client }; } -test('a lost start response retains its request id until the retry is acknowledged', async () => { +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[] = []; @@ -53,10 +53,14 @@ test('a lost start response retains its request id until the retry is acknowledg 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]).toBe(requests[0]); + expect(requests[1] === requests[0]).toBe(!changeSelection); } finally { store.detach(); client.close(); } }); @@ -147,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(); From 807d48d76caaf6aee2a0e89d9dc6f038c8fbb335 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:26:07 +0200 Subject: [PATCH 5/6] fix(ui): align phone command highlighting with input --- packages/ui/src/components/Composer.svelte | 2 +- tests/e2e/readability.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/Composer.svelte b/packages/ui/src/components/Composer.svelte index 01af7bb..2655059 100644 --- a/packages/ui/src/components/Composer.svelte +++ b/packages/ui/src/components/Composer.svelte @@ -1020,7 +1020,7 @@ } @media (max-width: 720px) { - textarea { font-size: var(--text-md); } + textarea, .input-mirror { font-size: var(--text-md); } .composer-wrap { padding: 6px 10px 10px; } diff --git a/tests/e2e/readability.test.ts b/tests/e2e/readability.test.ts index 8fb7c59..d715882 100644 --- a/tests/e2e/readability.test.ts +++ b/tests/e2e/readability.test.ts @@ -89,6 +89,12 @@ 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'); return { inputFont: getComputedStyle(input).fontSize, mirrorFont: getComputedStyle(mirror).fontSize, inputHeight: input.scrollHeight, mirrorHeight: mirror.scrollHeight }; })()`); + expect(phoneText.mirrorFont).toBe(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); From 39780eae5ad03b97536e7dcb84a3f4f5600a6728 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:31:12 +0200 Subject: [PATCH 6/6] test(ui): compare phone composer typography --- tests/e2e/readability.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/e2e/readability.test.ts b/tests/e2e/readability.test.ts index d715882..18ff98b 100644 --- a/tests/e2e/readability.test.ts +++ b/tests/e2e/readability.test.ts @@ -91,8 +91,16 @@ test('goal prompts and markers stay readable and recognized commands are accente 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'); return { inputFont: getComputedStyle(input).fontSize, mirrorFont: getComputedStyle(mirror).fontSize, inputHeight: input.scrollHeight, mirrorHeight: mirror.scrollHeight }; })()`); - expect(phoneText.mirrorFont).toBe(phoneText.inputFont); + 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'`);