mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 15:06:21 +00:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6948a3f5f5 | ||
|
|
d57e210f84 | ||
|
|
44a3bf2520 | ||
|
|
998086d6fb | ||
|
|
6ea388a206 | ||
|
|
651cdd257a | ||
|
|
e3c2e635a9 | ||
|
|
91a4c7bc32 | ||
|
|
eead95e712 | ||
|
|
c2a7616beb | ||
|
|
48c8a308b9 | ||
|
|
1bd1f72bcf | ||
|
|
311e32da93 | ||
|
|
90501dd6e3 | ||
|
|
499e22bf52 | ||
|
|
dfe3052bb6 | ||
|
|
fa4f8a66c2 | ||
|
|
9391ee8efc | ||
|
|
6d6e2a9f68 | ||
|
|
fa6fb71a83 | ||
|
|
8068c5e48c | ||
|
|
8525035bbe | ||
|
|
2c3f94f0ba | ||
|
|
745a1c0ee6 | ||
|
|
7188e22bdc | ||
|
|
8fc93e6ee4 | ||
|
|
6e87cd66bf | ||
|
|
8f97a0986a | ||
|
|
fbcc2d9855 | ||
|
|
b4447e6be8 | ||
|
|
3cfec5ab34 | ||
|
|
b467432ba4 | ||
|
|
8d4ef01621 | ||
|
|
335e4ca56f | ||
|
|
e327f93711 | ||
|
|
a978a1e010 | ||
|
|
e561431f7c | ||
|
|
519cd8c771 | ||
|
|
34e40cc4bc | ||
|
|
e0c7712f20 | ||
|
|
ddda404d99 | ||
|
|
0b771030ed | ||
|
|
4d0eb97ddf | ||
|
|
d117a33982 | ||
|
|
46c33630b7 | ||
|
|
74eda7f950 | ||
|
|
01093db365 | ||
|
|
95d788f8eb | ||
|
|
49f9a60087 | ||
|
|
48927df2ff | ||
|
|
2de99a2885 | ||
|
|
c8f81c8b83 | ||
|
|
f92a725dfd | ||
|
|
7f2645a8f4 | ||
|
|
5ee7f19875 | ||
|
|
e76e90b71e | ||
|
|
c806503694 | ||
|
|
fd73a85de4 | ||
|
|
e2e82f18e2 | ||
|
|
91cdb182f0 | ||
|
|
316f7925d3 | ||
|
|
9f01e2b548 | ||
|
|
7856515140 | ||
|
|
818804e181 | ||
|
|
ce6247bd2f | ||
|
|
0465328297 | ||
|
|
43d09b9d75 | ||
|
|
7730cec123 | ||
|
|
367ee47d7e | ||
|
|
c596a8f11d | ||
|
|
30f998c5b9 | ||
|
|
d4b4dd17cc | ||
|
|
f330f3e02b | ||
|
|
a9c27209bb | ||
|
|
831f8f6cd1 | ||
|
|
cff1d0fe01 | ||
|
|
e297da82ae | ||
|
|
77eac47493 | ||
|
|
aadc0c1b4b | ||
|
|
94caa36fd4 | ||
|
|
a6f75d483a | ||
|
|
000b42d204 | ||
|
|
02440f6715 | ||
|
|
d46ed9e9db | ||
|
|
71779ae5de | ||
|
|
4a3e25beab | ||
|
|
23fde448ec | ||
|
|
1137861188 | ||
|
|
815d4ab9b4 | ||
|
|
5d73a5789f | ||
|
|
df05945042 | ||
|
|
6a99898ef7 | ||
|
|
a40a87276a | ||
|
|
a20cbc394e | ||
|
|
8fda87614f | ||
|
|
dffd95ce7c | ||
|
|
b0402f5a34 | ||
|
|
54b00ec5fe | ||
|
|
6dd1733bbf | ||
|
|
663c2dc1ce | ||
|
|
01eda4c178 | ||
|
|
a6b49b3f74 | ||
|
|
5b2276666f | ||
|
|
cc0cc59700 | ||
|
|
57a9decefe | ||
|
|
c0220ddd8b | ||
|
|
b31defc0a5 | ||
|
|
e7d42f83e6 | ||
|
|
db768c4886 | ||
|
|
9553187ba6 | ||
|
|
d04257eeb4 | ||
|
|
d68f425c17 |
@@ -417,7 +417,6 @@ jobs:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name == 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
@@ -480,7 +479,7 @@ jobs:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
|
||||
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.1",
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.3",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
@@ -592,8 +592,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.9",
|
||||
"@opentui/solid": ">=0.5.9",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -730,6 +730,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/simulation": {
|
||||
@@ -969,6 +970,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -978,6 +980,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -1047,6 +1050,7 @@
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
@@ -1076,9 +1080,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -1593,7 +1597,7 @@
|
||||
|
||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||
|
||||
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-aPG0ct8ctxAqndbNOx7NW0GhU6QY6sOUfi/DaKqH9c5WdxICSsUop6uSkJwPDHP9WpN9eg0dd2D2qwYpG6UdHw=="],
|
||||
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-iiIwlNoycOMUiaUzL1ZirLapCG3WY8cg9hSj4KgE/JYIWgqauoHikIlfUt7SdMqqGIkb4iZu6I1MxgDDJaRfBA=="],
|
||||
|
||||
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
|
||||
|
||||
@@ -2219,27 +2223,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3211,8 +3215,6 @@
|
||||
|
||||
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
|
||||
|
||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
@@ -5451,8 +5453,6 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
@@ -5667,8 +5667,6 @@
|
||||
|
||||
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
|
||||
|
||||
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -5817,9 +5815,7 @@
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@@ -5851,8 +5847,6 @@
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
||||
@@ -6263,6 +6257,8 @@
|
||||
|
||||
"@opencode-ai/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode-ai/session-ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
|
||||
|
||||
"@opencode-ai/stats-app/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode-ai/storybook/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
|
||||
@@ -6607,8 +6603,6 @@
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
|
||||
@@ -87,6 +87,11 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
cd packages/desktop
|
||||
|
||||
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
|
||||
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
--config electron-builder.config.ts \
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
|
||||
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
|
||||
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
|
||||
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
|
||||
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
|
||||
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
|
||||
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
|
||||
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -178,6 +178,7 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +397,9 @@ export interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
|
||||
// Item ids are response-scoped identities. Keep completed ids tombstoned so
|
||||
// reconnect replay cannot reopen fragments already emitted downstream.
|
||||
readonly completedMessages: ReadonlySet<string>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
@@ -952,12 +955,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id !== undefined) {
|
||||
const itemID = item.id
|
||||
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
|
||||
const phase = messagePhase(item.phase)
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
|
||||
// A new message closes earlier messages, including ones that never streamed.
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = [...state.lifecycle.text]
|
||||
.filter((id) => id !== itemID)
|
||||
.reduce((lifecycle, id) => {
|
||||
completedMessages.add(id)
|
||||
const openPhase = state.message?.id === id ? state.message.phase : undefined
|
||||
return Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
@@ -970,6 +977,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
completedMessages,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
@@ -1086,7 +1094,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
completedMessages.add(item.id)
|
||||
if (state.message !== undefined && state.message.id !== item.id)
|
||||
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
|
||||
const message = state.message
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
@@ -1099,13 +1112,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
message: message ? undefined : state.message,
|
||||
completedMessages,
|
||||
message: undefined,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1244,9 +1257,12 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
if (
|
||||
item.type !== "function_call" ||
|
||||
!item.call_id ||
|
||||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
|
||||
)
|
||||
continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
@@ -1419,6 +1435,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
message: undefined,
|
||||
completedMessages: new Set<string>(),
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles a done-only message once across replayed item events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [{ type: "output_text", text: "Recovered" }],
|
||||
}
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.output_item.added", item },
|
||||
{ type: "response.output_item.done", item },
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Recovered")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Recovered",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
|
||||
@@ -216,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
|
||||
|
||||
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
|
||||
Effect.gen(function* () {
|
||||
const text = {
|
||||
type: "message",
|
||||
id: "msg_text",
|
||||
content: [{ type: "output_text", text: "Done-only text." }],
|
||||
}
|
||||
const refusal = {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
content: [{ type: "refusal", refusal: "Done-only refusal." }],
|
||||
}
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{ type: "response.output_item.done", item: text },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
{ type: "response.output_item.done", item: refusal },
|
||||
completed,
|
||||
)
|
||||
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_text",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_text",
|
||||
text: "Done-only text.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_refusal",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_refusal",
|
||||
text: "Done-only refusal.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a repeated message lifecycle as replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
@@ -233,9 +289,44 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores a stale done-only message while another message is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_1",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_1", text: "Draft" },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text: "Final",
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
|
||||
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses completed response output when output item completion is missing", () =>
|
||||
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
@@ -4032,7 +4032,6 @@ describe("OpenAI Responses route", () => {
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
|
||||
|
||||
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
|
||||
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
|
||||
})
|
||||
|
||||
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--empty-draft")
|
||||
await component.locator('[data-action="composer-attach"]').click()
|
||||
|
||||
const shortcuts = page.locator('[role="menu"] [data-slot="menu-v2-item-shortcut"]')
|
||||
await expect(shortcuts).toHaveCount(4)
|
||||
const boxes = await shortcuts.evaluateAll((items) =>
|
||||
items.map((item) => {
|
||||
const box = item.getBoundingClientRect()
|
||||
return { width: box.width, center: box.left + box.width / 2 }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(new Set(boxes.map((box) => box.width)).size).toBe(1)
|
||||
expect(new Set(boxes.map((box) => box.center)).size).toBe(1)
|
||||
})
|
||||
|
||||
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
|
||||
const component = await mount(`opencode-composer-flow--${draft}`)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("keeps the comment options button pressed while its menu is open", async ({ mount, page }) => {
|
||||
const component = await mount("ui-line-comment--display")
|
||||
const trigger = component.locator('[data-slot="line-comment-v2-overflow"]')
|
||||
const rest = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
|
||||
await trigger.click()
|
||||
|
||||
await expect(page.getByRole("menu")).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("data-expanded", "")
|
||||
await expect(trigger).not.toHaveCSS("background-color", rest)
|
||||
})
|
||||
@@ -85,14 +85,16 @@ Each tab scenario reports one sample, including its raw observations. Use Playwr
|
||||
|
||||
The tab-switch workload uses two equally long sessions: 200 user/assistant exchanges (400 messages) per tab. Every answer includes headings, emphasis, links, a blockquote, task and nested lists, an eight-row table, and four highlighted code fences (TSX, JSON, SQL, Bash), alongside the stress fixture's reasoning and tools. The mock API deliberately returns all 400 messages in one response so every scenario measures a long loaded history, not a short paginated tail. The viewport is fixed at 1440 x 900. Results include the fixture version, Markdown and serialized-message byte counts, and message-request count. These numbers are not directly comparable to the earlier 12-exchange source / 72-exchange destination fixture.
|
||||
|
||||
Cold means the destination transcript has never rendered in that fresh browser context. Warm means its complex answer was rendered and ready before switching away and back. Both use the app's normal restored-tab data prefetch, which completes before measurement; neither includes app startup, the source session's Markdown engine initialization, or a cold backend fetch. The suite asserts no message fetch during either measured switch. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
|
||||
Cold means the destination transcript has never loaded or rendered in that fresh browser context. Its measured switch includes one fixture message fetch. Warm means its complex answer was rendered and ready before switching away and back, and asserts no message fetch during the measured switch. Neither includes app startup or the source session's Markdown engine initialization. These cold results are not comparable to older prefetched cold-render results. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
|
||||
|
||||
```sh
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
|
||||
```
|
||||
|
||||
**The tab-switch fixture is not an end-to-end cold-data benchmark.** It prefetches destination messages and returns full history. Measure cold API navigation, Home-row opening, and prefetched-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
|
||||
**The tab-switch fixture returns full history, not normal pagination.** Measure cold API navigation, Home-row opening, and restored-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
|
||||
|
||||
`inactive-tab-prefetch-benchmark.spec.ts` restores eight tabs with normal 20-message pages (44 parts and 139,257 response bytes per page). It gates heavy responses independently until every tab's attention callback has run, then measures selection with ready answer Markdown and bottom anchoring. A separate case closes an inactive tab before releasing the responses. The fixture reports speculative transcript/inbox reads, request concurrency, response bytes, and activation latency. Set `OPENCODE_PERFORMANCE_MEMORY=1` only in separate retention runs; those force GC before selection and must not be mixed into clean timing results. The scope is the production browser renderer, not total desktop memory. Live background events and eviction of previously visited transcripts are separate workloads.
|
||||
|
||||
Keep one-off reports, recorded results, and traces outside git, in the ignored `e2e/performance/results/` directory or an external artifact directory. Preserve raw observations locally and publish anonymized summaries and charts in the PR description, not as committed experiment files.
|
||||
|
||||
@@ -104,7 +106,7 @@ bun run bench:tabs
|
||||
|
||||
This runs only the tab-switch benchmark against the production build with 20 serial repetitions and no retries. It prints the median (mean of the two middle values for even sample counts) and nearest-rank p95 for `firstCorrectObservedMs` and `stableObservedMs` per scenario. Only records whose benchmark and Playwright statuses are passed and whose two metrics are finite enter the summary. Test and record statuses, missing records, and excluded samples are reported separately.
|
||||
|
||||
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from prefetched tab remounts.
|
||||
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from cached tab remounts.
|
||||
|
||||
For milestone charts, rerun frozen builds with one workload and counterbalanced serial order. Do not connect historical medians from different transports, preparation, or machine-load periods. Show samples or ranges, name the checkpoints accurately, and distinguish experimental build snapshots from Git commits.
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Composer History Hydration
|
||||
|
||||
Manual benchmark for an empty destination composer. Runs the production
|
||||
`ComposerEditor`, `createComposerEditor`, `createComposerHistory`, persistence
|
||||
codec, and browser IndexedDB draft store. It does not run the surrounding app
|
||||
shell or native desktop IPC.
|
||||
|
||||
Workload: 100 normal prompts with realistic review instructions/code and 100
|
||||
shell commands. Separate cases have no images, 50 unique screenshots, or 50
|
||||
references to 5 screenshots. The fixture generates valid 1440 x 900 PNG code
|
||||
screenshots before timing and reports their exact byte sizes. Each isolated
|
||||
browser context measures a cold URL-cache mount followed by a warm remount.
|
||||
The database was just seeded; this does not simulate a cold disk cache.
|
||||
|
||||
`historyReadyMs` measures the mount action until both production history stores
|
||||
are populated. This is history availability, not time to first editable input
|
||||
(input can be usable before history finishes). The benchmark then verifies
|
||||
ArrowUp recall and a decoded screenshot in the real editor. `recallObservedMs`
|
||||
includes Playwright action/assertion overhead and is reported separately.
|
||||
`mountRecallObservedMs` includes the mount, readiness checks, keyboard action,
|
||||
and correct text/image completion; it also includes Playwright overhead.
|
||||
IndexedDB reads and blob sizes are mechanism metrics, not desktop IPC bytes or
|
||||
process memory. No timing threshold is enforced.
|
||||
|
||||
From `packages/app`, set `OPENCODE_HISTORY_BUILD` and
|
||||
`OPENCODE_HISTORY_OUTPUT` to artifact directories outside Git, then run:
|
||||
|
||||
```sh
|
||||
bun x vite build --config e2e/performance/composer-history/vite.config.ts
|
||||
bun x playwright test --config e2e/performance/composer-history/playwright.config.ts --repeat-each=20
|
||||
```
|
||||
|
||||
The preview server owns port 4783 and is stopped by Playwright. Preserve each
|
||||
build and its revision/hash for comparisons. `BENCHMARK` JSON lines contain all
|
||||
raw samples. Optional Chrome traces use the existing
|
||||
`OPENCODE_PERFORMANCE_TRACE_DIR` setting; keep trace runs separate from clean
|
||||
timing. Screenshots are captured after timing on the first repeat only.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
|
||||
benchmark.use({ traceScope: "page" })
|
||||
for (const shape of ["text", "unique", "repeated"]) {
|
||||
benchmark(`composer global history: ${shape}, cold and warm mounts`, async ({ page, report }, testInfo) => {
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
await page.goto(`/?shape=${shape}`)
|
||||
const button = page.getByRole("button", { name: "Mount empty composer", exact: true })
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const samples = []
|
||||
for (const cache of ["cold", "warm"]) {
|
||||
await expect(button).toBeEnabled()
|
||||
const mountStarted = performance.now()
|
||||
await button.click()
|
||||
await expect(page.getByTestId("history-ready")).toHaveText("ready")
|
||||
await expect(input).toBeEditable()
|
||||
await expect(input).toBeEmpty()
|
||||
const result = JSON.parse((await page.getByTestId("history-result").textContent())!)
|
||||
expect(result.documents).toBe(2)
|
||||
expect(result.historyReadyMs).toBeGreaterThan(0)
|
||||
const start = performance.now()
|
||||
await input.press("ArrowUp")
|
||||
await expect(input).toContainText("Review the retry policy in src/network/request-0.ts.")
|
||||
const images = page.getByRole("img", { name: "request-0.png", exact: true })
|
||||
await expect(images).toHaveCount(shape === "text" ? 0 : 1)
|
||||
if (shape !== "text")
|
||||
await expect
|
||||
.poll(() => images.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth === 1440))
|
||||
.toBe(true)
|
||||
samples.push({
|
||||
cache,
|
||||
...result,
|
||||
recallObservedMs: performance.now() - start,
|
||||
mountRecallObservedMs: performance.now() - mountStarted,
|
||||
})
|
||||
}
|
||||
expect(errors).toEqual([])
|
||||
report(
|
||||
{ samples },
|
||||
{
|
||||
browser: page.context().browser()!.version(),
|
||||
scope: "production composer editor/history, browser IndexedDB; no native IPC",
|
||||
},
|
||||
)
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${shape}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { createEffect, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
|
||||
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createComposerHistory } from "@/composer/history/store"
|
||||
import { ComposerEditor } from "@/composer/editor/editor"
|
||||
import { createComposerEditor } from "@/composer/editor/interaction"
|
||||
import type { ComposerPersistedState } from "@/composer/types"
|
||||
import "@/index.css"
|
||||
|
||||
const shape = new URLSearchParams(location.search).get("shape") ?? "text"
|
||||
const normal = Array.from({ length: 100 }, (_, index) => {
|
||||
const content =
|
||||
`Review the retry policy in src/network/request-${index}.ts. Preserve cancellation and the existing error messages.\n\n` +
|
||||
`The request should stop after three attempts. Add coverage for a 429 response, a connection reset, and a successful retry. Verify that only idempotent requests are retried.\n\n` +
|
||||
`Report ${index}:\n\`\`\`ts\nexport async function request(input: Request) {\n const response = await fetch(input)\n if (!response.ok) throw new Error(response.statusText)\n return response.json()\n}\n\`\`\``
|
||||
return {
|
||||
prompt: [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
...(shape !== "text" && index % 2 === 0
|
||||
? [
|
||||
{
|
||||
type: "image",
|
||||
id: `attachment-${index}`,
|
||||
filename: `request-${index}.png`,
|
||||
mime: "image/png",
|
||||
blob: { id: `screenshot-${shape === "repeated" ? index % 10 : index}` },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
comments: [],
|
||||
}
|
||||
})
|
||||
const shell = Array.from({ length: 100 }, (_, index) => {
|
||||
const content = `bun test src/network/request-${index}.test.ts --timeout 30000`
|
||||
return { prompt: [{ type: "text", content, start: 0, end: content.length }], comments: [] }
|
||||
})
|
||||
|
||||
// Seed only this Playwright context, before opening the production draft store.
|
||||
const request = indexedDB.open("opencode-drafts", 1)
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("documents")
|
||||
request.result.createObjectStore("blobs")
|
||||
}
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
const ids = [...new Set(normal.flatMap((entry) => entry.prompt.flatMap((part) => (part.blob ? [part.blob.id] : []))))]
|
||||
const screenshots: { id: string; blob: Blob }[] = []
|
||||
for (const id of ids) {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = 1440
|
||||
canvas.height = 900
|
||||
const context = canvas.getContext("2d")!
|
||||
context.fillStyle = "#15191f"
|
||||
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
context.font = "16px monospace"
|
||||
context.fillStyle = "#b8c8d8"
|
||||
context.fillText(`request.ts - ${id}`, 30, 35)
|
||||
for (let line = 0; line < 38; line++) {
|
||||
context.fillStyle = line % 3 ? "#a8c7ba" : "#d4a882"
|
||||
context.fillText(
|
||||
`${String(line + 1).padStart(3)} const response${line} = await fetch('/api/request/${id}/${line}', { signal, headers });`,
|
||||
30,
|
||||
70 + line * 20,
|
||||
)
|
||||
}
|
||||
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png"))
|
||||
screenshots.push({ id, blob })
|
||||
}
|
||||
const transaction = db.transaction(["documents", "blobs"], "readwrite")
|
||||
transaction.objectStore("documents").put(JSON.stringify({ entries: normal }), "opencode.global.dat:prompt-history")
|
||||
transaction.objectStore("documents").put(JSON.stringify({ entries: shell }), "opencode.global.dat:prompt-history-shell")
|
||||
screenshots.forEach(({ id, blob }) => transaction.objectStore("blobs").put(blob, id))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
db.close()
|
||||
|
||||
const metrics = { reads: 0, blobBytes: 0, documents: 0 }
|
||||
const originalGet = IDBObjectStore.prototype.get
|
||||
IDBObjectStore.prototype.get = function (key) {
|
||||
const request = originalGet.call(this, key)
|
||||
if (this.name === "documents") metrics.documents++
|
||||
if (this.name === "blobs") {
|
||||
metrics.reads++
|
||||
request.addEventListener("success", () => {
|
||||
metrics.blobBytes += request.result?.size ?? 0
|
||||
})
|
||||
}
|
||||
return request
|
||||
}
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
draftStore: createBrowserDraftStore(),
|
||||
openExternal() {},
|
||||
restart: async () => {},
|
||||
notify: async () => {},
|
||||
}
|
||||
const [state, setState] = createStore({ mount: 0, ready: false, result: "" })
|
||||
const workload = {
|
||||
shape,
|
||||
normalEntries: normal.length,
|
||||
shellEntries: shell.length,
|
||||
imageReferences: shape === "text" ? 0 : 50,
|
||||
uniqueImages: ids.length,
|
||||
storedImageBytes: screenshots.reduce((sum, item) => sum + item.blob.size, 0),
|
||||
documentBytes: [normal, shell].reduce(
|
||||
(sum, entries) => sum + new TextEncoder().encode(JSON.stringify({ entries })).length,
|
||||
0,
|
||||
),
|
||||
screenshotDimensions: [1440, 900],
|
||||
}
|
||||
let started = 0
|
||||
function mount() {
|
||||
metrics.reads = 0
|
||||
metrics.blobBytes = 0
|
||||
metrics.documents = 0
|
||||
setState({ ready: false, result: "" })
|
||||
started = performance.now()
|
||||
setState("mount", state.mount + 1)
|
||||
}
|
||||
function Destination() {
|
||||
// Same history creation and editor mapping as createComposerModel. Destination draft is empty.
|
||||
const history = createComposerHistory()
|
||||
const store = createStore<ComposerPersistedState>({
|
||||
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
cursor: 0,
|
||||
context: { items: [] },
|
||||
})
|
||||
const controller = createComposerEditor({
|
||||
store,
|
||||
commands: () => [],
|
||||
context: () => [],
|
||||
searchContextFiles: () => [],
|
||||
history: {
|
||||
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
|
||||
add: (prompt, mode) => history.add(prompt, mode, []),
|
||||
},
|
||||
view: {
|
||||
placeholder: () => "Empty destination composer",
|
||||
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
|
||||
},
|
||||
})
|
||||
createEffect(() => {
|
||||
if (history.entries("normal").length !== 100 || history.entries("shell").length !== 100) return
|
||||
setState({
|
||||
ready: true,
|
||||
result: JSON.stringify({ historyReadyMs: performance.now() - started, ...metrics, ...workload }),
|
||||
})
|
||||
})
|
||||
return <ComposerEditor controller={controller} />
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<main style={{ padding: "40px", width: "900px" }}>
|
||||
<h1>Composer global history: {shape}</h1>
|
||||
<button onClick={mount}>Mount empty composer</button>
|
||||
<output data-testid="history-ready">{state.ready ? "ready" : "idle"}</output>
|
||||
<pre data-testid="history-result">{state.result}</pre>
|
||||
<Show when={state.mount} keyed>
|
||||
{(_mount) => <Destination />}
|
||||
</Show>
|
||||
</main>
|
||||
</PlatformProvider>
|
||||
),
|
||||
document.getElementById("root")!,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Composer history benchmark</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "composer-history.bench.ts",
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
reporter: "line",
|
||||
outputDir: process.env.OPENCODE_HISTORY_OUTPUT,
|
||||
use: { baseURL: "http://127.0.0.1:4783", viewport: { width: 1440, height: 900 }, trace: "off", video: "off" },
|
||||
webServer: {
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
command:
|
||||
"bun x vite preview --config e2e/performance/composer-history/vite.config.ts --host 127.0.0.1 --port 4783 --strictPort",
|
||||
url: "http://127.0.0.1:4783",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import app from "../../../vite"
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
|
||||
plugins: [app],
|
||||
build: { target: "esnext", outDir: process.env.OPENCODE_HISTORY_BUILD, emptyOutDir: true },
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
# Timeline Preload Lifetime
|
||||
|
||||
This manual benchmark uses the production app, restored session tabs, the real
|
||||
`MessageTimeline` preload, and the real Markdown worker. Only API data and result
|
||||
delivery timing are fixture-owned. It does not connect to a running OpenCode
|
||||
service or send prompts.
|
||||
|
||||
From `packages/app`, set absolute `MARKDOWN_APP_BUILD_DIR` and
|
||||
`MARKDOWN_RESULTS_DIR` artifact paths, then run:
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
# Copy dist into MARKDOWN_APP_BUILD_DIR before editing production source.
|
||||
bun --bun x playwright test --config e2e/performance/markdown/playwright.config.ts --repeat-each 20
|
||||
```
|
||||
|
||||
Each isolated sample restores two sessions with one user message and one completed
|
||||
assistant text part each. The cold target has a realistic recovery review with
|
||||
either two TypeScript fences (typical) or 36 fences (large). The source has a short
|
||||
completed answer. Target data is prefetched before selection, but its Markdown is
|
||||
not parsed until the target is selected.
|
||||
|
||||
The app's service-worker generator reads `dist`, so use the normal build output
|
||||
and freeze a copy, rather than overriding Vite's build output directory.
|
||||
|
||||
The real worker result is held after admission. The test selects the original
|
||||
session again and releases the held result only after the abandoned timeline row
|
||||
detaches and the selected answer reports production Markdown readiness. This
|
||||
exercises both the timeline preload and the nested Markdown consumer, including
|
||||
the case where either one would otherwise keep a shared parse alive.
|
||||
|
||||
Destination readiness and post-disposal result settlement are separate metrics.
|
||||
The latter is a MessageChannel task after the result's promise microtasks drain.
|
||||
The DOMParser probe counts actual DOMPurify input containing the abandoned answer
|
||||
after disposal, in characters. CDP reports renderer task/script time and JS heap.
|
||||
These are not worker CPU, Electron process RAM, or ungated tab-switch measurements.
|
||||
In particular, this gate releases the result after destination readiness and must
|
||||
not be used to claim a destination-readiness gain from skipping sanitization.
|
||||
|
||||
`MARKDOWN_ASSERT_DISPOSAL=1` enables the no-obsolete-sanitization assertion. Use
|
||||
`MARKDOWN_RETAINED=1` only in separate post-GC runs. The repository trace collector
|
||||
is available through `OPENCODE_PERFORMANCE_TRACE_DIR`, and `MARKDOWN_SCREENSHOT`
|
||||
captures final output after timing. Run serially, preserve frozen builds, and keep
|
||||
all results outside Git.
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
import { completedAnswer } from "../../../../session-ui/performance/markdown-lifetime/answer"
|
||||
import { installMarkdownGate } from "./probe"
|
||||
|
||||
for (const size of ["typical", "large"]) {
|
||||
benchmark(`timeline preload disposal: ${size}`, async ({ page, report }) => {
|
||||
const answer = completedAnswer(size === "typical" ? 2 : 36)
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((id) => [
|
||||
id,
|
||||
[
|
||||
{
|
||||
id: `msg_1_${id}_user`,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review the recovery boundary.",
|
||||
},
|
||||
{
|
||||
id: `msg_2_${id}_assistant`,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000008000 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
id === fixture.targetID
|
||||
? answer
|
||||
: "## Current destination\n\nThe selected session is ready.\n\n```typescript\nconst current = { ready: true }\n```",
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
]),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions.filter((session) => session.id !== fixture.childID),
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (id) => ({ items: messages[id] ?? [] }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
const targetPart = `msg_2_${fixture.targetID}_assistant:text:0`
|
||||
const sourcePart = `msg_2_${fixture.sourceID}_assistant:text:0`
|
||||
await installMarkdownGate(page, { answer, targetPart, sourcePart, href: stressSessionHref(fixture.sourceID) })
|
||||
const prefetched = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await prefetched
|
||||
const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`)
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").held)
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached()
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
await page.evaluate(() => Reflect.get(window, "markdownGate").arm())
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0)
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").settled > 0)
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const stats = await page.evaluate(() => {
|
||||
const value = Reflect.get(window, "markdownGate")
|
||||
return {
|
||||
admitted: value.admitted,
|
||||
responses: value.responses,
|
||||
started: value.started,
|
||||
ready: value.ready,
|
||||
released: value.released,
|
||||
settled: value.settled,
|
||||
sanitizeCalls: value.sanitizeCalls,
|
||||
sanitizeChars: value.sanitizeChars,
|
||||
}
|
||||
})
|
||||
expect(stats.admitted).toBe(1)
|
||||
expect(stats.responses).toBe(1)
|
||||
expect(stats.ready).toBeGreaterThan(stats.started)
|
||||
expect(stats.settled).toBeGreaterThan(stats.released)
|
||||
expect(errors).toEqual([])
|
||||
if (process.env.MARKDOWN_ASSERT_DISPOSAL === "1") expect(stats.sanitizeCalls).toBe(0)
|
||||
const value = (data: typeof after, name: string) => data.metrics.find((item) => item.name === name)!.value
|
||||
const retained = process.env.MARKDOWN_RETAINED === "1"
|
||||
if (retained) await cdp.send("HeapProfiler.collectGarbage")
|
||||
report(
|
||||
{
|
||||
...stats,
|
||||
destinationReadyMs: stats.ready - stats.started,
|
||||
releasedSettledMs: stats.settled - stats.released,
|
||||
taskMs: (value(after, "TaskDuration") - value(before, "TaskDuration")) * 1000,
|
||||
scriptMs: (value(after, "ScriptDuration") - value(before, "ScriptDuration")) * 1000,
|
||||
usedHeapBytes: (await cdp.send("Runtime.getHeapUsage")).usedSize,
|
||||
},
|
||||
{
|
||||
size,
|
||||
retained,
|
||||
answerBytes: Buffer.byteLength(answer),
|
||||
messagesPerSession: 2,
|
||||
partsPerAnswer: 1,
|
||||
fences: size === "typical" ? 2 : 36,
|
||||
browser: page.context().browser()!.version(),
|
||||
transport: "playwright-route",
|
||||
build: process.env.MARKDOWN_APP_BUILD_DIR,
|
||||
},
|
||||
)
|
||||
if (process.env.MARKDOWN_SCREENSHOT)
|
||||
await page.screenshot({ path: `${process.env.MARKDOWN_SCREENSHOT}/timeline-${size}.png` })
|
||||
await cdp.detach()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.env.PLAYWRIGHT_PORT = "6199"
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = "6199"
|
||||
process.env.PLAYWRIGHT_SERVER_HOST = "127.0.0.1"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.bench.ts",
|
||||
outputDir: process.env.MARKDOWN_RESULTS_DIR,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [["line"]],
|
||||
use: { baseURL: "http://127.0.0.1:6199", viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
|
||||
webServer: {
|
||||
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
|
||||
command: `bun run serve -- --host 127.0.0.1 --port 6199 --strictPort --outDir "${process.env.MARKDOWN_APP_BUILD_DIR}"`,
|
||||
url: "http://127.0.0.1:6199",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import type {
|
||||
MarkdownWorkerRequest,
|
||||
MarkdownWorkerResponse,
|
||||
} from "../../../../session-ui/src/components/markdown-worker-protocol"
|
||||
|
||||
export async function installMarkdownGate(
|
||||
page: Page,
|
||||
input: { answer: string; sourcePart: string; targetPart: string; href: string },
|
||||
) {
|
||||
await page.addInitScript(({ answer, sourcePart, targetPart, href }) => {
|
||||
const stats = {
|
||||
admitted: 0,
|
||||
responses: 0,
|
||||
held: false,
|
||||
started: 0,
|
||||
ready: 0,
|
||||
released: 0,
|
||||
settled: 0,
|
||||
sanitizeCalls: 0,
|
||||
sanitizeChars: 0,
|
||||
arm: () => {
|
||||
armed = true
|
||||
},
|
||||
}
|
||||
let armed = false
|
||||
let id: number | undefined
|
||||
let release: (() => void) | undefined
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, "onmessage")!
|
||||
const post = Worker.prototype.postMessage
|
||||
Object.defineProperty(Worker.prototype, "onmessage", {
|
||||
configurable: true,
|
||||
get: descriptor.get,
|
||||
set(callback: (event: MessageEvent<MarkdownWorkerResponse>) => void) {
|
||||
descriptor.set!.call(this, (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
if (event.data.type === "parse" && event.data.id === id) {
|
||||
stats.responses++
|
||||
stats.held = true
|
||||
release = () => callback.call(this, event)
|
||||
return
|
||||
}
|
||||
callback.call(this, event)
|
||||
})
|
||||
},
|
||||
})
|
||||
Worker.prototype.postMessage = function (request: MarkdownWorkerRequest) {
|
||||
if (request.type === "parse" && request.text === answer) {
|
||||
id = request.id
|
||||
stats.admitted++
|
||||
}
|
||||
post.call(this, request)
|
||||
}
|
||||
const parse = DOMParser.prototype.parseFromString
|
||||
DOMParser.prototype.parseFromString = function (text, type) {
|
||||
if (stats.released && String(text).includes("Recovery implementation review")) {
|
||||
stats.sanitizeCalls++
|
||||
stats.sanitizeChars += String(text).length
|
||||
}
|
||||
return parse.call(this, text, type)
|
||||
}
|
||||
document.addEventListener(
|
||||
"mousedown",
|
||||
(event) => {
|
||||
if (!armed || stats.started) return
|
||||
const target = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (target?.getAttribute("href") !== href) return
|
||||
stats.started = performance.now()
|
||||
},
|
||||
true,
|
||||
)
|
||||
// The app can retain the outgoing view until the destination is ready. Release
|
||||
// only after its actual row detaches, rather than assuming click means dispose.
|
||||
new MutationObserver(() => {
|
||||
if (!stats.started || stats.released) return
|
||||
const current = document.querySelector(`[data-timeline-part-id="${sourcePart}"] [data-markdown-ready]`)
|
||||
if (!current) return
|
||||
stats.ready ||= performance.now()
|
||||
if (document.querySelector(`[data-timeline-part-id="${targetPart}"]`)) return
|
||||
stats.released = performance.now()
|
||||
performance.mark("markdown-timeline-disposed")
|
||||
release!()
|
||||
release = undefined
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = () => {
|
||||
stats.settled = performance.now()
|
||||
channel.port1.close()
|
||||
channel.port2.close()
|
||||
}
|
||||
channel.port2.postMessage(null)
|
||||
}).observe(document, { childList: true, subtree: true, attributes: true })
|
||||
Object.defineProperty(window, "markdownGate", { value: stats })
|
||||
}, input)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Patch Group Benchmark
|
||||
|
||||
This manual benchmark mounts the production `CurrentFileToolGroup` and `File`
|
||||
components with completed edit results. A separate case mounts `ToolDisplay`
|
||||
with a patch result. It uses four real Core tool source files, with deterministic
|
||||
identifier renames, rather than repeated filler. It does not connect to a server.
|
||||
|
||||
From `packages/app`, set `PATCH_BUILD_DIR` and `PATCH_RESULTS_DIR` to external
|
||||
artifact directories, then run:
|
||||
|
||||
```sh
|
||||
bun x vite build --config e2e/performance/patch-groups/vite.config.ts
|
||||
bun x playwright test --config e2e/performance/patch-groups/playwright.config.ts --repeat-each=20
|
||||
```
|
||||
|
||||
Run under the shared exclusive gate when collecting measurements on a shared
|
||||
machine. The Playwright-owned static server uses `PATCH_PORT` (default 4317),
|
||||
refuses to reuse an existing server, and shuts down after the run.
|
||||
|
||||
Each fresh browser context measures a cold collapsed mount, a warm remount,
|
||||
and opening `edit.ts` through its real accordion. Mount timing covers synchronous
|
||||
component construction through layout. Expansion timing starts at the click and
|
||||
ends at the production file renderer's `onRendered` callback. Assertions check
|
||||
the exact file count, collapsed state, and completed file rendering. Results
|
||||
include payload bytes, source bytes, file/tool counts, and supporting warm
|
||||
`patchFileGroups` timings with and without reading views. No timing thresholds
|
||||
are enforced. This is a browser component workload, not a full desktop memory test.
|
||||
|
||||
Freeze the build before changing production code. Use the same fixture, browser,
|
||||
viewport, sample count, and completion checks for both revisions.
|
||||
|
||||
`PATCH_REVISION=<git-sha>` loads the grouping module and tool renderer from that
|
||||
revision at build time without changing the worktree. This is useful when fixing
|
||||
the harness after freezing a baseline. All other production sources must match
|
||||
between revisions; this switch only covers those two measured modules.
|
||||
|
||||
For a separate diagnostic build, set `PATCH_COUNTERS=1`. Its build-only transform
|
||||
counts grouping, normalization, reconstruction, and line-diff calls with User
|
||||
Timing marks. Do not mix instrumented results with clean timings. Set
|
||||
`OPENCODE_PERFORMANCE_TRACE_DIR` for the existing Chrome trace collector, and
|
||||
`PATCH_SCREENSHOTS=1` for collapsed/expanded screenshots after measurement.
|
||||
@@ -0,0 +1,143 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { render } from "solid-js/web"
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { CurrentSessionProviders } from "../../../../session-ui/src/storybook/current-session-story"
|
||||
import { emptySessionDocument } from "../../../../session-ui/src/storybook/current-session-fixtures"
|
||||
import { CurrentFileToolGroup, ToolDisplay } from "../../../../session-ui/src/tools/tool-renderer"
|
||||
import { patchFileGroups } from "../../../../session-ui/src/components/apply-patch-file"
|
||||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import edit from "../../../../core/src/tool/plugin/edit.ts?raw"
|
||||
import patch from "../../../../core/src/tool/plugin/patch.ts?raw"
|
||||
import read from "../../../../core/src/tool/plugin/read.ts?raw"
|
||||
import shell from "../../../../core/src/tool/plugin/shell.ts?raw"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const scenario = new URLSearchParams(location.search).get("scenario") ?? "complete"
|
||||
const sources = [edit, patch, read, shell].map((text) => text.replaceAll("\r\n", "\n"))
|
||||
const names = ["edit", "patch", "read", "shell"]
|
||||
const changed = (text: string) => text.replaceAll(/\bcontext\b/g, "invocation")
|
||||
const entry = (index: number, before: string, after: string) => ({
|
||||
file: `src/tool/plugin/${names[index]}.ts`,
|
||||
patch: createTwoFilesPatch(names[index], names[index], before, after, "", "", {
|
||||
context: scenario === "partial" ? 3 : Infinity,
|
||||
}),
|
||||
...diffLines(before, after).reduce(
|
||||
(counts, item) => ({
|
||||
additions: counts.additions + (item.added ? item.count : 0),
|
||||
deletions: counts.deletions + (item.removed ? item.count : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
),
|
||||
status: "modified" as const,
|
||||
})
|
||||
const files =
|
||||
scenario === "multi"
|
||||
? sources.map((text, index) => entry(index, text, changed(text)))
|
||||
: [
|
||||
entry(0, sources[0], changed(sources[0])),
|
||||
...(scenario === "chained"
|
||||
? [entry(0, changed(sources[0]), changed(sources[0]).replaceAll(/\binput\b/g, "parameters"))]
|
||||
: []),
|
||||
]
|
||||
const tools: SessionMessageAssistantTool[] = files.map((file, index) => ({
|
||||
id: `fixture-edit-${index}`,
|
||||
type: "tool",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: file.file, oldString: "context", newString: "invocation", replaceAll: true },
|
||||
metadata: { files: [file] },
|
||||
content: [{ type: "text", text: `Edited ${file.file}` }],
|
||||
},
|
||||
time: { created: 1, ran: 2, completed: 3 },
|
||||
}))
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
patchBenchmark: {
|
||||
payloadBytes: number
|
||||
sourceBytes: number
|
||||
files: number
|
||||
tools: number
|
||||
grouping: (expanded: boolean) => { ms: number; groups: number; views: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
window.patchBenchmark = {
|
||||
payloadBytes: new TextEncoder().encode(JSON.stringify(tools)).length,
|
||||
sourceBytes: new TextEncoder().encode(sources.slice(0, scenario === "multi" ? 4 : 1).join("")).length,
|
||||
files: new Set(files.map((file) => file.file)).size,
|
||||
tools: tools.length,
|
||||
grouping(expanded) {
|
||||
const start = performance.now()
|
||||
const groups = patchFileGroups(files)
|
||||
const views = expanded ? groups.reduce((count, file) => count + file.views.length, 0) : 0
|
||||
return { ms: performance.now() - start, groups: groups.length, views }
|
||||
},
|
||||
}
|
||||
|
||||
function Fixture() {
|
||||
const [state, setState] = createStore({ mounted: false, duration: 0, rendered: 0 })
|
||||
let start = 0
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<section style={{ margin: "24px auto", "max-width": "960px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
start = performance.now()
|
||||
setState("mounted", true)
|
||||
document.querySelector("[data-component=apply-patch-tool]")!.getBoundingClientRect()
|
||||
setState("duration", performance.now() - start)
|
||||
}}
|
||||
>
|
||||
Mount tools
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setState({ mounted: false, rendered: 0 })
|
||||
}}
|
||||
>
|
||||
Unmount tools
|
||||
</button>
|
||||
<output data-testid="mount-ms">{state.duration}</output>
|
||||
<output data-testid="rendered">{state.rendered}</output>
|
||||
<div
|
||||
on:click={{
|
||||
capture: true,
|
||||
handleEvent() {
|
||||
start = performance.now()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Show when={state.mounted}>
|
||||
<CurrentSessionProviders document={emptySessionDocument}>
|
||||
<Show
|
||||
when={scenario === "direct"}
|
||||
fallback={
|
||||
<CurrentFileToolGroup
|
||||
tools={tools}
|
||||
onSizeChange={() => setState("rendered", performance.now() - start)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ToolDisplay
|
||||
id="fixture-patch"
|
||||
tool="patch"
|
||||
input={{}}
|
||||
metadata={{ files }}
|
||||
status="completed"
|
||||
onContentRendered={() => setState("rendered", performance.now() - start)}
|
||||
/>
|
||||
</Show>
|
||||
</CurrentSessionProviders>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
render(() => <Fixture />, document.getElementById("root")!)
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Patch groups benchmark</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="./fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
|
||||
for (const scenario of ["complete", "partial", "chained", "multi", "direct"]) {
|
||||
benchmark(`patch groups ${scenario}`, async ({ page, report }, info) => {
|
||||
await page.goto(`/?scenario=${scenario}`)
|
||||
await expect(page.getByRole("button", { name: "Mount tools", exact: true })).toBeEnabled()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
expect(await page.evaluate(() => document.fonts.check('13px "Inter"'))).toBe(true)
|
||||
const shape = await page.evaluate(() => {
|
||||
const { grouping, ...shape } = window.patchBenchmark
|
||||
performance.clearMarks()
|
||||
return shape
|
||||
})
|
||||
const mount = async () => {
|
||||
await page.getByRole("button", { name: "Mount tools", exact: true }).click()
|
||||
await expect(page.locator('[data-slot="apply-patch-filename"]')).toHaveCount(shape.files)
|
||||
await expect(page.locator('[data-component="file"]')).toHaveCount(0)
|
||||
return Number(await page.getByTestId("mount-ms").textContent())
|
||||
}
|
||||
const cold = await mount()
|
||||
const counters = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
["patchFileGroups", "normalize", "completePatchContents", "diffLines"].map((name) => [
|
||||
name,
|
||||
performance.getEntriesByName(`patch-counter:${name}`).length,
|
||||
]),
|
||||
),
|
||||
)
|
||||
await page.getByRole("button", { name: "Unmount tools", exact: true }).click()
|
||||
await expect(page.locator('[data-component="apply-patch-tool"]')).toHaveCount(0)
|
||||
await page.evaluate(() => performance.clearMarks())
|
||||
const warm = await mount()
|
||||
const warmCounters = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
["patchFileGroups", "normalize", "completePatchContents", "diffLines"].map((name) => [
|
||||
name,
|
||||
performance.getEntriesByName(`patch-counter:${name}`).length,
|
||||
]),
|
||||
),
|
||||
)
|
||||
const file = page.locator('[data-scope="apply-patch"] button').filter({ hasText: "edit.ts" })
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.getByTestId("rendered")).not.toHaveText("0")
|
||||
await expect(page.locator('[data-component="file"]')).toBeVisible()
|
||||
const expansion = Number(await page.getByTestId("rendered").textContent())
|
||||
const grouping = await page.evaluate(() => ({
|
||||
collapsed: window.patchBenchmark.grouping(false),
|
||||
expanded: window.patchBenchmark.grouping(true),
|
||||
}))
|
||||
expect(grouping.collapsed.groups).toBe(shape.files)
|
||||
report(
|
||||
{ cold, warm, expansion, grouping, counters, warmCounters },
|
||||
{ scenario, ...shape, scope: "production tool components" },
|
||||
)
|
||||
if (process.env.PATCH_SCREENSHOTS === "1") {
|
||||
await page.screenshot({ path: info.outputPath(`${scenario}-expanded.png`) })
|
||||
await file.click()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
await page
|
||||
.locator('[data-component="apply-patch-tool"]')
|
||||
.screenshot({ path: info.outputPath(`${scenario}-collapsed.png`) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
const baseURL = `http://127.0.0.1:${process.env.PATCH_PORT ?? 4317}`
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.bench.ts",
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
outputDir: process.env.PATCH_RESULTS_DIR,
|
||||
reporter: "line",
|
||||
use: { baseURL, viewport: { width: 1366, height: 768 }, colorScheme: "light" },
|
||||
webServer: {
|
||||
command: "bun serve.ts",
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from "node:path"
|
||||
|
||||
const directory = process.env.PATCH_BUILD_DIR
|
||||
if (!directory) throw new Error("PATCH_BUILD_DIR is required")
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.PATCH_PORT ?? 4317),
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
const file = Bun.file(path.join(directory, pathname === "/" ? "index.html" : pathname))
|
||||
return (await file.exists()) ? new Response(file) : new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
|
||||
plugins: [
|
||||
solid(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: "patch-group-counters",
|
||||
enforce: "pre",
|
||||
load(id) {
|
||||
if (!process.env.PATCH_REVISION) return
|
||||
const root = fileURLToPath(new URL("../../../../..", import.meta.url))
|
||||
const file = path.relative(root, id).replaceAll("\\", "/")
|
||||
if (
|
||||
![
|
||||
"packages/session-ui/src/components/apply-patch-file.ts",
|
||||
"packages/session-ui/src/tools/tool-renderer.tsx",
|
||||
].includes(file)
|
||||
)
|
||||
return
|
||||
return execFileSync("git", ["show", `${process.env.PATCH_REVISION}:${file}`], { cwd: root, encoding: "utf8" })
|
||||
},
|
||||
transform(code, id) {
|
||||
if (process.env.PATCH_COUNTERS !== "1") return
|
||||
const functions = id.replaceAll("\\", "/").endsWith("/apply-patch-file.ts")
|
||||
? ["patchFileGroups"]
|
||||
: id.replaceAll("\\", "/").endsWith("/session-diff.ts")
|
||||
? ["normalize", "completePatchContents"]
|
||||
: id.replaceAll("\\", "/").endsWith("/diff/line.js")
|
||||
? ["diffLines"]
|
||||
: []
|
||||
for (const name of functions) {
|
||||
const pattern = new RegExp(`(export function ${name}\\([^)]*\\)[^{]*\\{)`)
|
||||
if (!pattern.test(code)) throw new Error(`Missing instrumented function ${name} in ${id}`)
|
||||
code = code.replace(pattern, `$1 performance.mark("patch-counter:${name}");`)
|
||||
}
|
||||
return functions.length ? { code, map: null } : undefined
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: { dedupe: ["solid-js", "@solidjs/meta"] },
|
||||
worker: { format: "es" },
|
||||
build: { outDir: process.env.PATCH_BUILD_DIR, emptyOutDir: true, sourcemap: true },
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
# Native Terminal Benchmark
|
||||
|
||||
Manual Windows benchmark. Run only in an isolated development worktree. It does
|
||||
not connect to an OpenCode service, user profile, or database.
|
||||
|
||||
Build from `packages/app` with
|
||||
`bun x vite build --config e2e/performance/terminals/vite.config.ts`, then freeze
|
||||
`dist` outside the repository. Set `PLAYWRIGHT_BUILD=1`, `PLAYWRIGHT_BASE_URL` to
|
||||
an unused loopback URL, `TERMINAL_BUILD` to the frozen build,
|
||||
`TERMINAL_ARTIFACTS` to an existing external directory, and `TERMINAL_RESULTS`
|
||||
to an external result directory. Run:
|
||||
|
||||
```sh
|
||||
bun x playwright test --config e2e/performance/terminals/playwright.config.ts --repeat-each=20
|
||||
```
|
||||
|
||||
The runner owns its preview server and each test owns a PowerShell ConPTY process.
|
||||
Session metadata is deterministic. Native output is forwarded through Playwright's
|
||||
WebSocket fixture into the real production `Terminal`, writer, Ghostty WASM/canvas,
|
||||
and serializer. No output is dropped, paused, or delayed. This is native terminal
|
||||
plus production renderer evidence, not the production PTY backend or Electron IPC.
|
||||
|
||||
The workload is 12,000 colored build/test log lines with file paths, durations, and
|
||||
result descriptions. Cases separate visible output, the same output while hidden,
|
||||
and closing the session tab after filling the configured scrollback. Ghostty
|
||||
converts the app's 10,000-line setting to bytes at its initial 80-column width;
|
||||
resizing reduces the effective row capacity. The report records actual retained
|
||||
rows and the first retained fixture record rather than assuming 10,000 rows. Completion
|
||||
requires the final marker in Ghostty and completion of its write callbacks, not
|
||||
just WebSocket delivery. Teardown requires Home readiness and the final serialized
|
||||
snapshot. Input, focus, resizing, and native process survival are checked.
|
||||
|
||||
`probe.ts` is included only by this benchmark build. It observes actual writes,
|
||||
renderer calls, and serialization. Chrome `TaskDuration` measures renderer task
|
||||
time, not total process CPU or RAM. For attribution, set
|
||||
`OPENCODE_PERFORMANCE_TRACE_DIR`; keep traced runs separate from clean timing.
|
||||
`TERMINAL_DRAW_PROBE=1` separately counts actual canvas draws to verify hidden
|
||||
rendering; do not mix these instrumented samples with clean timing.
|
||||
Use `TERMINAL_REVISION` and `TERMINAL_BUNDLE` to identify frozen artifacts.
|
||||
`TERMINAL_SCREENSHOTS` captures the visible result after timing.
|
||||
|
||||
The benchmark has no machine-dependent performance thresholds. Keep raw logs,
|
||||
snapshots, traces, and screenshots outside Git. Run heavy work through the
|
||||
coordinator's exclusive gate when participating in a shared performance wave.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
import config from "../../../playwright.config"
|
||||
|
||||
export default defineConfig({
|
||||
...config,
|
||||
testDir: ".",
|
||||
testIgnore: [],
|
||||
testMatch: "terminal-benchmark.spec.ts",
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 120_000,
|
||||
outputDir: process.env.TERMINAL_RESULTS,
|
||||
reporter: [["line"]],
|
||||
webServer: {
|
||||
command: `bun x vite preview --host 127.0.0.1 --port ${new URL(process.env.PLAYWRIGHT_BASE_URL!).port} --strictPort --outDir ${process.env.TERMINAL_BUILD}`,
|
||||
url: process.env.PLAYWRIGHT_BASE_URL,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
use: { ...config.use, viewport: { width: 1440, height: 900 }, trace: "off", video: "off", serviceWorkers: "block" },
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Terminal } from "ghostty-web"
|
||||
import { SerializeAddon } from "../../../src/session/terminal/serialize"
|
||||
|
||||
export type TerminalProbe = {
|
||||
term?: Terminal
|
||||
writes: number
|
||||
pending: number
|
||||
bytes: number
|
||||
renders: number
|
||||
hiddenRenders: number
|
||||
draws: number
|
||||
hiddenDraws: number
|
||||
serialized: { ms: number; bytes: number; value: string }[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
terminalProbe: TerminalProbe
|
||||
}
|
||||
}
|
||||
|
||||
const probe: TerminalProbe = {
|
||||
writes: 0,
|
||||
pending: 0,
|
||||
bytes: 0,
|
||||
renders: 0,
|
||||
hiddenRenders: 0,
|
||||
draws: 0,
|
||||
hiddenDraws: 0,
|
||||
serialized: [],
|
||||
}
|
||||
window.terminalProbe = probe
|
||||
const open = Terminal.prototype.open
|
||||
Terminal.prototype.open = function (element) {
|
||||
probe.term = this
|
||||
open.call(this, element)
|
||||
// Ghostty does not expose render events. This benchmark-only wrapper observes its
|
||||
// actual renderer; it does not alter scheduling, parsing, or drawing.
|
||||
const renderer = (this as unknown as { renderer: { render: (...args: unknown[]) => void } }).renderer
|
||||
const render = renderer.render
|
||||
let hidden = false
|
||||
renderer.render = function (...args) {
|
||||
probe.renders++
|
||||
hidden = !element.checkVisibility()
|
||||
if (hidden) probe.hiddenRenders++
|
||||
return render.apply(this, args)
|
||||
}
|
||||
if (new URL(location.href).searchParams.has("terminalDrawProbe")) {
|
||||
const context = element.querySelector("canvas")!.getContext("2d")!
|
||||
const draw = context.drawImage
|
||||
context.drawImage = function (...args: unknown[]) {
|
||||
probe.draws++
|
||||
if (hidden) probe.hiddenDraws++
|
||||
Reflect.apply(draw, this, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
const write = Terminal.prototype.write
|
||||
Terminal.prototype.write = function (data, done) {
|
||||
probe.writes++
|
||||
probe.pending++
|
||||
probe.bytes += typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength
|
||||
return write.call(this, data, () => {
|
||||
probe.pending--
|
||||
done?.()
|
||||
})
|
||||
}
|
||||
const serialize = SerializeAddon.prototype.serialize
|
||||
SerializeAddon.prototype.serialize = function (options) {
|
||||
const start = performance.now()
|
||||
const value = serialize.call(this, options)
|
||||
probe.serialized.push({ ms: performance.now() - start, bytes: new TextEncoder().encode(value).byteLength, value })
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
param([Parameter(Mandatory = $true)][string]$Fixture)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::WriteLine('TERMINAL_FIXTURE_READY')
|
||||
while ($null -ne ($command = [Console]::ReadLine())) {
|
||||
if ($command -eq 'exit') { exit 0 }
|
||||
if ($command -eq 'run') {
|
||||
foreach ($line in [System.IO.File]::ReadLines($Fixture)) {
|
||||
[Console]::WriteLine($line)
|
||||
}
|
||||
[Console]::WriteLine('TERMINAL_WORKLOAD_DONE')
|
||||
}
|
||||
if ($command -eq 'ping') { [Console]::WriteLine('TERMINAL_PROCESS_ALIVE') }
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { createRequire } from "node:module"
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import type {} from "./probe"
|
||||
|
||||
// Use the same installed native PTY package as Core, with a fixture-owned process.
|
||||
const native = createRequire(new URL("../../../../core/package.json", import.meta.url))("@lydell/node-pty") as {
|
||||
spawn: (
|
||||
file: string,
|
||||
args: string[],
|
||||
options: { cols: number; rows: number; cwd: string },
|
||||
) => {
|
||||
pid: number
|
||||
write: (data: string) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
kill: () => void
|
||||
onData: (handler: (data: string) => void) => { dispose: () => void }
|
||||
onExit: (handler: () => void) => { dispose: () => void }
|
||||
}
|
||||
}
|
||||
|
||||
const sessionID = "ses_terminal_benchmark"
|
||||
const ptyID = "pty_terminal_benchmark"
|
||||
const title = "Terminal build output"
|
||||
const server = process.env.PLAYWRIGHT_BASE_URL!
|
||||
const href = `/server/${Buffer.from(server).toString("base64url")}/session/${sessionID}`
|
||||
const lines = Array.from({ length: 12_000 }, (_, i) => {
|
||||
const unit = ["session/history", "session/runner", "project/discovery", "tool/shell", "provider/stream"][i % 5]
|
||||
return `\x1b[32mPASS\x1b[0m packages/core/test/${unit}-${String(i).padStart(5, "0")}.test.ts \x1b[2m[${10 + (i % 237)}ms]\x1b[0m validates ordered output and durable recovery`
|
||||
}).join("\r\n")
|
||||
|
||||
benchmark.use({ traceScope: "interaction", viewport: { width: 1440, height: 900 } })
|
||||
|
||||
for (const scenario of ["visible-output", "hidden-output", "full-scrollback-teardown"] as const) {
|
||||
benchmark(scenario, async ({ page, report }, info) => {
|
||||
const dir = await mkdtemp(path.join(process.env.TERMINAL_ARTIFACTS ?? tmpdir(), "terminal-fixture-"))
|
||||
await writeFile(path.join(dir, "build.log"), lines)
|
||||
const pty = native.spawn(
|
||||
"pwsh.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-File",
|
||||
fileURLToPath(new URL("./shell.ps1", import.meta.url)),
|
||||
"-Fixture",
|
||||
path.join(dir, "build.log"),
|
||||
],
|
||||
{ cols: 120, rows: 24, cwd: dir },
|
||||
)
|
||||
const exited = new Promise<void>((resolve) => pty.onExit(resolve))
|
||||
let output = ""
|
||||
let connected = 0
|
||||
let closed = 0
|
||||
let send: ((data: string) => void) | undefined
|
||||
const listener = pty.onData((data) => {
|
||||
output += data
|
||||
send?.(data)
|
||||
})
|
||||
const sizes: { cols: number; rows: number }[] = []
|
||||
const removals: string[] = []
|
||||
try {
|
||||
if (process.env.TERMINAL_DRAW_PROBE) {
|
||||
await page.addInitScript(() => {
|
||||
const fill = CanvasRenderingContext2D.prototype.fillText
|
||||
CanvasRenderingContext2D.prototype.fillText = function (...args: Parameters<typeof fill>) {
|
||||
if (this.canvas instanceof HTMLCanvasElement && this.canvas.closest('[data-component="terminal"]')) {
|
||||
window.terminalProbe.draws++
|
||||
if (!this.canvas.checkVisibility()) window.terminalProbe.hiddenDraws++
|
||||
}
|
||||
Reflect.apply(fill, this, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
const location = { directory: dir, project: { id: "proj_terminal_benchmark", directory: dir } }
|
||||
const data = {
|
||||
id: ptyID,
|
||||
title: "Terminal 1",
|
||||
command: "pwsh.exe",
|
||||
args: [],
|
||||
cwd: dir,
|
||||
status: "running",
|
||||
pid: pty.pid,
|
||||
}
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: dir,
|
||||
project: {
|
||||
id: location.project.id,
|
||||
worktree: dir,
|
||||
vcs: "git",
|
||||
name: "terminal-benchmark",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID: location.project.id,
|
||||
directory: dir,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/pty**", async (route) => {
|
||||
if (route.request().method() === "DELETE") removals.push(route.request().url())
|
||||
const body = route.request().postDataJSON()
|
||||
if (body?.size) {
|
||||
sizes.push(body.size)
|
||||
pty.resize(body.size.cols, body.size.rows)
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location,
|
||||
data: route.request().url().includes("connect-token") ? { ticket: "fixture", expires_in: 60 } : data,
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (socket) => {
|
||||
connected++
|
||||
send = (data) => socket.send(data)
|
||||
socket.send(output.slice(Number(new URL(socket.url()).searchParams.get("cursor") ?? 0)))
|
||||
socket.onMessage((data) => pty.write(String(data)))
|
||||
socket.onClose(() => {
|
||||
closed++
|
||||
send = undefined
|
||||
})
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ server, sessionID }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID },
|
||||
)
|
||||
await page.goto(
|
||||
`${href}${process.env.TERMINAL_DRAW_PROBE && scenario !== "full-scrollback-teardown" ? "?terminalDrawProbe" : ""}`,
|
||||
)
|
||||
await expectSessionTitle(page, title)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await waitForText(page, "TERMINAL_FIXTURE_READY")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const size = await page.evaluate(() => ({
|
||||
cols: window.terminalProbe.term!.cols,
|
||||
rows: window.terminalProbe.term!.rows,
|
||||
}))
|
||||
return sizes.at(-1)?.cols === size.cols && sizes.at(-1)?.rows === size.rows
|
||||
})
|
||||
.toBe(true)
|
||||
if (scenario === "hidden-output") {
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeHidden()
|
||||
}
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
const start = await page.evaluate(() => {
|
||||
window.terminalProbe.renders = 0
|
||||
window.terminalProbe.hiddenRenders = 0
|
||||
window.terminalProbe.draws = 0
|
||||
window.terminalProbe.hiddenDraws = 0
|
||||
return performance.now()
|
||||
})
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
// The producer is not throttled. The visible and hidden cases receive the same bytes.
|
||||
pty.write("run\r")
|
||||
await waitForText(page, "TERMINAL_WORKLOAD_DONE")
|
||||
const produced = await page.evaluate(
|
||||
(start) => ({
|
||||
ms: performance.now() - start,
|
||||
renders: window.terminalProbe.renders,
|
||||
hiddenRenders: window.terminalProbe.hiddenRenders,
|
||||
draws: window.terminalProbe.draws,
|
||||
hiddenDraws: window.terminalProbe.hiddenDraws,
|
||||
bytes: window.terminalProbe.bytes,
|
||||
scrollback: window.terminalProbe.term!.getScrollbackLength(),
|
||||
cols: window.terminalProbe.term!.cols,
|
||||
rows: window.terminalProbe.term!.rows,
|
||||
firstRecord: Number(
|
||||
window.terminalProbe
|
||||
.term!.buffer.normal.getLine(0)
|
||||
?.translateToString(true)
|
||||
.match(/-(\d{5})\.test\.ts/)?.[1],
|
||||
),
|
||||
}),
|
||||
start,
|
||||
)
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const cpuMs =
|
||||
(after.metrics.find((x) => x.name === "TaskDuration")!.value -
|
||||
before.metrics.find((x) => x.name === "TaskDuration")!.value) *
|
||||
1000
|
||||
let interaction: Record<string, unknown> = {}
|
||||
if (scenario === "hidden-output") {
|
||||
const start = await page.evaluate(() => performance.now())
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitForText(page, "TERMINAL_WORKLOAD_DONE")
|
||||
interaction = { returnMs: await page.evaluate((start) => performance.now() - start, start) }
|
||||
}
|
||||
if (scenario === "full-scrollback-teardown") {
|
||||
// Ghostty converts the configured line limit to bytes at the initial
|
||||
// 80-column size. Resizing changes the effective retained row count.
|
||||
expect(produced.scrollback).toBeGreaterThan(0)
|
||||
expect(produced.firstRecord).toBeGreaterThan(0)
|
||||
expect(produced.firstRecord).toBeLessThan(11_999)
|
||||
const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`)
|
||||
await expect(close).toBeVisible()
|
||||
const cpuBefore = await cdp.send("Performance.getMetrics")
|
||||
const start = await page.evaluate(() => performance.now())
|
||||
await close.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(page.locator('[data-component="home-session-search"]')).toBeVisible()
|
||||
await expect(page.locator('[data-component="home-session-search"] input')).toBeEditable()
|
||||
await expect.poll(() => page.evaluate(() => window.terminalProbe.serialized.length)).toBe(1)
|
||||
interaction = await page.evaluate(
|
||||
(start) => ({
|
||||
homeReadyMs: performance.now() - start,
|
||||
serializeMs: window.terminalProbe.serialized[0].ms,
|
||||
serializedBytes: window.terminalProbe.serialized[0].bytes,
|
||||
}),
|
||||
start,
|
||||
)
|
||||
const cpuAfter = await cdp.send("Performance.getMetrics")
|
||||
interaction.teardownCpuMs =
|
||||
(cpuAfter.metrics.find((x) => x.name === "TaskDuration")!.value -
|
||||
cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) *
|
||||
1000
|
||||
const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value)
|
||||
expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual(
|
||||
Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index),
|
||||
)
|
||||
expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE")
|
||||
await writeFile(
|
||||
path.join(
|
||||
process.env.TERMINAL_ARTIFACTS ?? tmpdir(),
|
||||
`${process.env.TERMINAL_BUNDLE}-${info.repeatEachIndex}.ansi`,
|
||||
),
|
||||
snapshot,
|
||||
)
|
||||
await expect(terminal).toHaveCount(0)
|
||||
expect(closed).toBe(1)
|
||||
// UI teardown must not terminate the native process.
|
||||
pty.write("ping\r")
|
||||
await expect.poll(() => output.includes("TERMINAL_PROCESS_ALIVE")).toBe(true)
|
||||
}
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
expect(connected).toBe(1)
|
||||
expect(removals).toEqual([])
|
||||
expect(sizes.length).toBeGreaterThan(0)
|
||||
report(
|
||||
{ ...produced, cpuMs, ...interaction },
|
||||
{
|
||||
revision: process.env.TERMINAL_REVISION,
|
||||
bundle: process.env.TERMINAL_BUNDLE,
|
||||
fixtureBytes: Buffer.byteLength(lines),
|
||||
fixtureLines: 12_000,
|
||||
transport: "Windows ConPTY -> Playwright WebSocket fixture -> production Terminal/writer/Ghostty",
|
||||
scope: "Chromium renderer; not Electron total RAM or production backend IPC",
|
||||
},
|
||||
)
|
||||
if (scenario !== "full-scrollback-teardown") {
|
||||
// Validate input, focus, and resize after both visible and hidden output.
|
||||
await terminal.click()
|
||||
await expect(terminal.locator("textarea")).toBeFocused()
|
||||
await page.keyboard.type("ping")
|
||||
await page.keyboard.press("Enter")
|
||||
await waitForText(page, "TERMINAL_PROCESS_ALIVE")
|
||||
const columns = await page.evaluate(() => window.terminalProbe.term!.cols)
|
||||
await page.setViewportSize({ width: 1100, height: 800 })
|
||||
await expect.poll(() => page.evaluate(() => window.terminalProbe.term!.cols)).not.toBe(columns)
|
||||
await expect
|
||||
.poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols)))
|
||||
.toBe(true)
|
||||
expect(closed).toBe(0)
|
||||
}
|
||||
if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") {
|
||||
await page.screenshot({
|
||||
path: path.join(process.env.TERMINAL_SCREENSHOTS, `${scenario}-${info.repeatEachIndex}.png`),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
listener.dispose()
|
||||
try {
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
// Stop fixture request handlers before killing their native resource. The
|
||||
// app debounces PTY resize requests independently of the canvas resize.
|
||||
await page.unrouteAll({ behavior: "wait" })
|
||||
await page.close()
|
||||
} finally {
|
||||
pty.kill()
|
||||
await exited
|
||||
await writeFile(
|
||||
path.join(
|
||||
process.env.TERMINAL_ARTIFACTS ?? tmpdir(),
|
||||
`${process.env.TERMINAL_BUNDLE}-${scenario}-${info.repeatEachIndex}.native.log`,
|
||||
),
|
||||
output,
|
||||
)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForText(page: Page, text: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((text) => {
|
||||
const probe = window.terminalProbe
|
||||
const term = probe?.term
|
||||
if (!term || probe.pending !== 0) return false
|
||||
const buffer = term.buffer.active
|
||||
return Array.from(
|
||||
{ length: term.rows },
|
||||
(_, i) => buffer.getLine(buffer.length - term.rows + i)?.translateToString(true) ?? "",
|
||||
)
|
||||
.join("\n")
|
||||
.includes(text)
|
||||
}, text),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mergeConfig } from "vite"
|
||||
import config from "../../../vite.config"
|
||||
|
||||
// The probe is included only in this manual benchmark build, never in the app build.
|
||||
export default mergeConfig(config, {
|
||||
plugins: [
|
||||
{
|
||||
name: "terminal-benchmark-probe",
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler: () => [
|
||||
{ tag: "script", attrs: { type: "module", src: "/e2e/performance/terminals/probe.ts" }, injectTo: "head" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
|
||||
const sessionID = "ses_composer_write_batch"
|
||||
const title = "Composer persistence workload"
|
||||
const addition = " Keep the existing error handling and add coverage."
|
||||
const text =
|
||||
Array.from(
|
||||
{ length: 180 },
|
||||
(_, index) =>
|
||||
`Review requirement ${index + 1}: preserve request ordering in src/queue/worker-${index % 12}.ts. ` +
|
||||
`A failed request must retain its payload, report its cause, and remain safe to retry.\n` +
|
||||
`Expected: await queue.flush(); expect(await repository.read(id)).toEqual(accepted);\n`,
|
||||
).join("") + "Implementation notes:"
|
||||
const items = Array.from({ length: 8 }, (_, index) => ({
|
||||
type: "file",
|
||||
path: `src/queue/worker-${index}.ts`,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 24, endChar: 0 },
|
||||
commentID: `composer-write-batch-${index}`,
|
||||
comment: `Check retry path ${index}: keep the original request identity and error cause.`,
|
||||
preview: Array.from(
|
||||
{ length: 24 },
|
||||
(_, line) => ` const request${line} = await repository.loadPending("queue-${index}");`,
|
||||
).join("\n"),
|
||||
}))
|
||||
const document = {
|
||||
prompt: [{ type: "text", content: text, start: 0, end: text.length }],
|
||||
cursor: text.length,
|
||||
mode: "normal",
|
||||
context: { items },
|
||||
}
|
||||
type Probe = { active: boolean; encodes: number; bytes: number; inputs: number; keyups: number }
|
||||
type ProbeWindow = typeof window & { composerWriteBatch: Probe }
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
video: "off",
|
||||
trace: "off",
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
})
|
||||
|
||||
for (const scenario of ["typing", "cursor-movement", "cursor-noop", "submit-cleanup"] as const) {
|
||||
benchmark(`composer-write-batch: ${scenario}`, async ({ page, report }, testInfo) => {
|
||||
const submitted: Record<string, unknown>[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [{ ...fixture.sessions[0], id: sessionID, title }],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt: (input) => submitted.push(input.body),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ key, value, counts }) => {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
const probe: Probe = { active: false, encodes: 0, bytes: 0, inputs: 0, keyups: 0 }
|
||||
;(window as ProbeWindow).composerWriteBatch = probe
|
||||
// The draft adapter parses each schema-encoded composer document once before
|
||||
// its asynchronous blob walk. Count at this boundary, not at the IDB write
|
||||
// (which already discards superseded writes). This fixture is ASCII only.
|
||||
if (counts) {
|
||||
const parse = JSON.parse
|
||||
JSON.parse = (value, reviver) => {
|
||||
if (probe.active && typeof value === "string" && value.startsWith('{"prompt":[')) {
|
||||
probe.encodes++
|
||||
probe.bytes += value.length
|
||||
}
|
||||
return parse(value, reviver)
|
||||
}
|
||||
}
|
||||
window.addEventListener("input", (event) => {
|
||||
if (
|
||||
probe.active &&
|
||||
event.target instanceof Element &&
|
||||
event.target.matches('[data-component="composer-editor"]')
|
||||
)
|
||||
probe.inputs++
|
||||
})
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (
|
||||
probe.active &&
|
||||
event.target instanceof Element &&
|
||||
event.target.matches('[data-component="composer-editor"]')
|
||||
)
|
||||
probe.keyups++
|
||||
})
|
||||
},
|
||||
{
|
||||
key: `${base64Encode(fixture.directory)}/prompt/${sessionID}.v2`,
|
||||
value: document,
|
||||
counts: process.env.OPENCODE_PERSISTENCE_COUNTS === "1",
|
||||
},
|
||||
)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(editor).toBeEditable()
|
||||
await expect(editor).toHaveText(text)
|
||||
await editor.focus()
|
||||
await editor.press("ControlOrMeta+End")
|
||||
await page.evaluate(() => window.document.fonts.ready)
|
||||
const stored = async () =>
|
||||
page.evaluate(async (sessionID) => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("opencode-drafts", 1)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
try {
|
||||
const transaction = db.transaction("documents")
|
||||
const keys = transaction.objectStore("documents").getAllKeys()
|
||||
const values = transaction.objectStore("documents").getAll()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
const index = keys.result.findIndex((key) => String(key).endsWith(`session:${sessionID}:prompt`))
|
||||
// Parse after disabling the count so the observation is not part of it.
|
||||
const probe = (window as ProbeWindow).composerWriteBatch
|
||||
const active = probe.active
|
||||
probe.active = false
|
||||
const value = index < 0 ? undefined : JSON.parse(values.result[index])
|
||||
probe.active = active
|
||||
return value as { prompt: { content: string }[]; cursor: number; context: { items: unknown[] } } | undefined
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}, sessionID)
|
||||
await expect.poll(async () => (await stored())?.cursor).toBe(text.length)
|
||||
expect((await stored())?.context.items).toHaveLength(items.length)
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
await page.evaluate(() => {
|
||||
;(window as ProbeWindow).composerWriteBatch.active = true
|
||||
performance.mark("composer-write-batch-start")
|
||||
})
|
||||
const start = performance.now()
|
||||
if (scenario === "typing") await editor.pressSequentially(addition)
|
||||
if (scenario === "cursor-movement") await editor.press("ArrowLeft")
|
||||
if (scenario === "cursor-noop") await editor.press("ArrowRight")
|
||||
if (scenario === "submit-cleanup") await editor.press("Enter")
|
||||
const expectedText = scenario === "typing" ? text + addition : scenario === "submit-cleanup" ? "" : text
|
||||
const expectedCursor =
|
||||
scenario === "typing"
|
||||
? text.length + addition.length
|
||||
: scenario === "submit-cleanup"
|
||||
? 0
|
||||
: text.length - Number(scenario === "cursor-movement")
|
||||
await expect(editor).toHaveText(expectedText)
|
||||
await expect.poll(async () => (await stored())?.cursor).toBe(expectedCursor)
|
||||
const elapsedMs = performance.now() - start
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const probe = await page.evaluate(() => {
|
||||
performance.mark("composer-write-batch-end")
|
||||
const probe = (window as ProbeWindow).composerWriteBatch
|
||||
probe.active = false
|
||||
return probe
|
||||
})
|
||||
expect((await stored())?.prompt.map((part) => part.content).join("")).toBe(expectedText)
|
||||
if (scenario === "submit-cleanup") {
|
||||
await expect.poll(() => submitted.length).toBe(1)
|
||||
expect(submitted[0].text).toContain(text)
|
||||
expect((await stored())?.context.items).toHaveLength(0)
|
||||
}
|
||||
expect(probe.keyups).toBe(scenario === "typing" ? addition.length : 1)
|
||||
expect(probe.inputs).toBe(scenario === "typing" ? addition.length : 0)
|
||||
const metric = (name: string) =>
|
||||
1000 *
|
||||
((after.metrics.find((x) => x.name === name)?.value ?? 0) -
|
||||
(before.metrics.find((x) => x.name === name)?.value ?? 0))
|
||||
report(
|
||||
{ elapsedMs, taskMs: metric("TaskDuration"), scriptMs: metric("ScriptDuration"), ...probe },
|
||||
{
|
||||
scenario,
|
||||
promptBytes: Buffer.byteLength(text),
|
||||
contextItems: items.length,
|
||||
persistedBytes: Buffer.byteLength(JSON.stringify(document)),
|
||||
typedCharacters: scenario === "typing" ? addition.length : 0,
|
||||
counts: process.env.OPENCODE_PERSISTENCE_COUNTS === "1",
|
||||
browser: page.context().browser()!.version(),
|
||||
build: process.env.OPENCODE_PERSISTENCE_BUILD,
|
||||
transport: "playwright-route",
|
||||
completion: "editor text and committed IDB cursor",
|
||||
},
|
||||
)
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
await cdp.detach()
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${scenario}.png`) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { APP_READY_TIMEOUT } from "../../utils/waits"
|
||||
import { fixture as stress } from "./session-timeline-stress.fixture"
|
||||
import { createHomeIndexFixture, type HomeIndexFixture } from "./home-session-index.fixture"
|
||||
|
||||
// Home fetches the root-session index on mount. These cases hold the visible
|
||||
// output constant (newest 64 rows, same order) while the index size grows, so
|
||||
// bytes, main-thread work, time to actionable rows, and retained heap can be
|
||||
// attributed to index handling rather than to what the user sees.
|
||||
const sizes = (process.env.HOME_INDEX_SIZES ?? "500,5000,10000").split(",").map(Number)
|
||||
const churnSize = Number(process.env.HOME_INDEX_CHURN_SIZE ?? 10_000)
|
||||
const updates = Number(process.env.HOME_INDEX_UPDATES ?? 20)
|
||||
// Forced GC changes timing; retention runs stay separate from clean timing runs.
|
||||
const memory = process.env.OPENCODE_PERFORMANCE_MEMORY === "1"
|
||||
|
||||
const rowContainer = '[data-component="home-session-row-container"]'
|
||||
const row = '[data-component="home-session-row"]'
|
||||
|
||||
type Probe = {
|
||||
expected: number
|
||||
rows?: number
|
||||
frame?: number
|
||||
pending: Record<string, string>
|
||||
titles: Record<string, number>
|
||||
}
|
||||
|
||||
type ProbeWindow = Window & {
|
||||
__homeIndexProbe?: Probe
|
||||
__mockServerStream?: { push: (payloads: unknown[]) => void }
|
||||
}
|
||||
|
||||
// Interaction-scoped tracing keeps the page-lifetime Chrome trace off unless a
|
||||
// scenario starts one; service workers stay out of the renderer measurement.
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
video: "off",
|
||||
trace: "off",
|
||||
serviceWorkers: "block",
|
||||
traceScope: "interaction",
|
||||
})
|
||||
|
||||
benchmark.describe("performance: home session index", () => {
|
||||
for (const count of sizes) {
|
||||
benchmark(`loads home with ${count} root sessions`, async ({ page, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const fixture = createHomeIndexFixture({ count, now: Date.now() })
|
||||
const network = await setup(page, fixture)
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
|
||||
await page.goto("/")
|
||||
const rows = page.locator(row)
|
||||
await expect(rows).toHaveCount(fixture.expected.visible, { timeout: APP_READY_TIMEOUT })
|
||||
const first = page.locator(rowContainer).filter({ hasText: fixture.expected.newestTitle })
|
||||
await expect(first).toHaveAttribute("data-session-id", fixture.expected.newestID)
|
||||
await expect(first.locator(row)).toBeEnabled()
|
||||
// Row order is part of the held-constant output: the DOM must list the
|
||||
// newest session first.
|
||||
await expect(page.locator(rowContainer).nth(0)).toHaveAttribute("data-session-id", fixture.expected.newestID)
|
||||
|
||||
const probe = await readProbe(page)
|
||||
const metrics = await performanceMetrics(cdp)
|
||||
const retained = memory ? await retainedHeap(cdp) : undefined
|
||||
await network.settle()
|
||||
if (testInfo.repeatEachIndex === 0) {
|
||||
const path = testInfo.outputPath(`home-${count}.png`)
|
||||
await page.screenshot({ path })
|
||||
await testInfo.attach(`home-${count}`, { path, contentType: "image/png" })
|
||||
}
|
||||
report(
|
||||
{
|
||||
listRequests: network.list.requests,
|
||||
listBytes: network.list.bytes,
|
||||
rowsMs: probe.rows,
|
||||
frameMs: probe.frame,
|
||||
listEndMs: probe.listEnd,
|
||||
processMs: probe.rows - probe.listEnd,
|
||||
// ThreadTime is main-thread CPU time; ScriptDuration only covers
|
||||
// Blink-invoked callbacks, so promise continuations are missing from it.
|
||||
threadMs: metrics.ThreadTime * 1000,
|
||||
scriptMs: metrics.ScriptDuration * 1000,
|
||||
taskMs: metrics.TaskDuration * 1000,
|
||||
layoutMs: metrics.LayoutDuration * 1000,
|
||||
styleMs: metrics.RecalcStyleDuration * 1000,
|
||||
heapUsedMB: metrics.JSHeapUsedSize / 1_048_576,
|
||||
heapTotalMB: metrics.JSHeapTotalSize / 1_048_576,
|
||||
nodes: metrics.Nodes,
|
||||
...(retained ? { retainedHeapMB: retained.usedSize / 1_048_576, retainedNodes: retained.nodes } : {}),
|
||||
},
|
||||
{
|
||||
sessions: count,
|
||||
directories: fixture.directories.length,
|
||||
fixtureVersion: fixture.version,
|
||||
fixtureListBytes: fixture.listBytes,
|
||||
visibleRows: fixture.expected.visible,
|
||||
gc: memory ? "explicit" : "none",
|
||||
scope: "renderer main isolate; not total desktop RAM",
|
||||
},
|
||||
)
|
||||
expect(probe.rows).toBeGreaterThan(0)
|
||||
await cdp.detach()
|
||||
})
|
||||
}
|
||||
|
||||
benchmark(
|
||||
`applies ${updates} background session updates on home with ${churnSize} root sessions`,
|
||||
async ({ page, report }) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const fixture = createHomeIndexFixture({ count: churnSize, now: Date.now() })
|
||||
const target = fixture.sessions[fixture.sessions.length - 1]
|
||||
const network = await setup(page, fixture)
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
|
||||
// Home prefetches the two newest sessions, which makes them locally known
|
||||
// and therefore part of every later index merge, like an open session.
|
||||
const prefetch = page.waitForResponse(
|
||||
(response) => response.request().method() === "GET" && response.url().includes(`/api/session/${target.id}`),
|
||||
)
|
||||
await page.goto("/")
|
||||
await expect(page.locator(row)).toHaveCount(fixture.expected.visible, { timeout: APP_READY_TIMEOUT })
|
||||
await prefetch
|
||||
const titleLocator = page.locator(
|
||||
`${rowContainer}[data-session-id="${target.id}"] [data-component="home-session-title"]`,
|
||||
)
|
||||
await expect(titleLocator).toHaveText(fixture.expected.newestTitle)
|
||||
|
||||
const before = await performanceMetrics(cdp)
|
||||
const samples: number[] = []
|
||||
for (let index = 1; index <= updates; index++) {
|
||||
const title = `${fixture.expected.newestTitle} · update ${index}`
|
||||
// The completed run bumps the session's updated time and title on the
|
||||
// server; the client re-reads the session and re-merges the index.
|
||||
target.title = title
|
||||
target.time.updated += 1000
|
||||
target.time.idle = target.time.updated
|
||||
const pushed = await page.evaluate(
|
||||
({ id, title, event }) => {
|
||||
const host = window as ProbeWindow
|
||||
if (!host.__homeIndexProbe || !host.__mockServerStream) throw new Error("Missing Home index probe")
|
||||
host.__homeIndexProbe.pending[id] = title
|
||||
host.__mockServerStream.push([event])
|
||||
return performance.now()
|
||||
},
|
||||
{
|
||||
id: target.id,
|
||||
title,
|
||||
event: {
|
||||
id: `evt_home_update_${index}`,
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: target.id },
|
||||
},
|
||||
},
|
||||
)
|
||||
await expect(titleLocator).toHaveText(title)
|
||||
const seen = await page.evaluate(({ title }) => (window as ProbeWindow).__homeIndexProbe?.titles[title], {
|
||||
title,
|
||||
})
|
||||
if (seen === undefined) throw new Error(`Probe did not observe title: ${title}`)
|
||||
samples.push(seen - pushed)
|
||||
}
|
||||
const after = await performanceMetrics(cdp)
|
||||
await network.settle()
|
||||
const sorted = samples.toSorted((a, b) => a - b)
|
||||
report(
|
||||
{
|
||||
updates,
|
||||
updateMs: sorted,
|
||||
updateMedianMs: median(sorted),
|
||||
updateP95Ms: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)],
|
||||
threadMs: (after.ThreadTime - before.ThreadTime) * 1000,
|
||||
scriptMs: (after.ScriptDuration - before.ScriptDuration) * 1000,
|
||||
taskMs: (after.TaskDuration - before.TaskDuration) * 1000,
|
||||
layoutMs: (after.LayoutDuration - before.LayoutDuration) * 1000,
|
||||
sessionReads: network.get.requests,
|
||||
},
|
||||
{
|
||||
sessions: churnSize,
|
||||
directories: fixture.directories.length,
|
||||
fixtureVersion: fixture.version,
|
||||
event: "session.execution.succeeded",
|
||||
scope: "renderer main isolate; latency from event push to row title update",
|
||||
},
|
||||
)
|
||||
expect(samples).toHaveLength(updates)
|
||||
await cdp.detach()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
async function setup(page: Page, fixture: HomeIndexFixture) {
|
||||
const primary = fixture.directories[0]
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: primary.directory,
|
||||
project: {
|
||||
id: primary.projectID,
|
||||
worktree: primary.directory,
|
||||
vcs: "git",
|
||||
name: primary.name,
|
||||
time: { created: fixture.now - 400 * 86_400_000, updated: fixture.now },
|
||||
sandboxes: [],
|
||||
},
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
provider: stress.provider,
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ projects }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: projects.map((worktree, index) => ({ worktree, expanded: index === 0 })) },
|
||||
lastProject: { local: projects[0] },
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ projects: fixture.directories.filter((entry) => entry.project).map((entry) => entry.directory) },
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ expected }) => {
|
||||
const host = window as ProbeWindow
|
||||
const probe: Probe = { expected, pending: {}, titles: {} }
|
||||
host.__homeIndexProbe = probe
|
||||
const observer = new MutationObserver(() => {
|
||||
if (probe.rows === undefined) {
|
||||
const count = document.querySelectorAll('[data-component="home-session-row"]').length
|
||||
if (count >= probe.expected) {
|
||||
probe.rows = performance.now()
|
||||
requestAnimationFrame((time) => {
|
||||
probe.frame = time
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const [id, title] of Object.entries(probe.pending)) {
|
||||
const element = document.querySelector(
|
||||
`[data-component="home-session-row-container"][data-session-id="${id}"] [data-component="home-session-title"]`,
|
||||
)
|
||||
if (element?.textContent !== title) continue
|
||||
probe.titles[title] = performance.now()
|
||||
delete probe.pending[id]
|
||||
}
|
||||
})
|
||||
// Init scripts run before <html> exists; the document node itself is always observable.
|
||||
observer.observe(document, { childList: true, subtree: true, characterData: true })
|
||||
},
|
||||
{ expected: fixture.expected.visible },
|
||||
)
|
||||
const list = { requests: 0, bytes: 0 }
|
||||
const get = { requests: 0, bytes: 0 }
|
||||
const pending: Promise<void>[] = []
|
||||
page.on("response", (response) => {
|
||||
const request = response.request()
|
||||
if (request.method() !== "GET") return
|
||||
const url = new URL(response.url())
|
||||
const isList = url.pathname === "/api/session"
|
||||
const isGet = /^\/api\/session\/[^/]+$/.test(url.pathname)
|
||||
if (!isList && !isGet) return
|
||||
const bucket = isList ? list : get
|
||||
bucket.requests += 1
|
||||
pending.push(
|
||||
response
|
||||
.body()
|
||||
.then((body) => {
|
||||
bucket.bytes += body.byteLength
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
})
|
||||
return {
|
||||
list,
|
||||
get,
|
||||
settle: () => Promise.all(pending).then(() => {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
const probe = await page.evaluate(() => {
|
||||
const host = window as ProbeWindow
|
||||
if (!host.__homeIndexProbe) throw new Error("Missing Home index probe")
|
||||
// Resource timing marks when the last index page finished arriving, so
|
||||
// rows - listEnd isolates parse, merge, and render from transfer and boot.
|
||||
const listEnd = Math.max(
|
||||
0,
|
||||
...performance
|
||||
.getEntriesByType("resource")
|
||||
.filter((entry) => new URL(entry.name).pathname === "/api/session")
|
||||
.map((entry) => (entry as PerformanceResourceTiming).responseEnd),
|
||||
)
|
||||
return { rows: host.__homeIndexProbe.rows, frame: host.__homeIndexProbe.frame, listEnd }
|
||||
})
|
||||
if (probe.rows === undefined) throw new Error("Probe did not observe the expected Home rows")
|
||||
return { rows: probe.rows, frame: probe.frame, listEnd: probe.listEnd }
|
||||
}
|
||||
|
||||
async function performanceMetrics(cdp: CDPSession) {
|
||||
const result = await cdp.send("Performance.getMetrics")
|
||||
return Object.fromEntries(result.metrics.map((metric) => [metric.name, metric.value])) as Record<string, number>
|
||||
}
|
||||
|
||||
async function retainedHeap(cdp: CDPSession) {
|
||||
// GC is an explicit retained-heap measurement, not an application optimization or readiness wait.
|
||||
await cdp.send("HeapProfiler.collectGarbage")
|
||||
const heap = await cdp.send("Runtime.getHeapUsage")
|
||||
const dom = await cdp.send("Memory.getDOMCounters")
|
||||
return { usedSize: heap.usedSize, nodes: dom.nodes }
|
||||
}
|
||||
|
||||
function median(sorted: number[]) {
|
||||
if (sorted.length === 0) return undefined
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { currentSession } from "../../utils/mock-server"
|
||||
|
||||
export const HOME_INDEX_FIXTURE_VERSION = 1
|
||||
|
||||
// Home shows the newest 64 root sessions; the fixture asserts that many rows.
|
||||
export const HOME_INDEX_VISIBLE_LIMIT = 64
|
||||
|
||||
export type HomeIndexSession = {
|
||||
id: string
|
||||
projectID: string
|
||||
title?: string
|
||||
agent: string
|
||||
model: { id: string; providerID: string; variant: string }
|
||||
cost: number
|
||||
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle: number; viewed?: number }
|
||||
location: { directory: string }
|
||||
}
|
||||
|
||||
export type HomeIndexDirectory = {
|
||||
directory: string
|
||||
name: string
|
||||
projectID: string
|
||||
// Local project entries appear in the Home project list; the rest model
|
||||
// sessions whose project was removed from the sidebar.
|
||||
project: boolean
|
||||
}
|
||||
|
||||
const repos = [
|
||||
"opencode",
|
||||
"storefront-api",
|
||||
"billing-worker",
|
||||
"design-system",
|
||||
"mobile-app",
|
||||
"infra-terraform",
|
||||
"docs-site",
|
||||
"analytics-pipeline",
|
||||
"auth-service",
|
||||
"legacy-admin",
|
||||
"notebooks",
|
||||
"dotfiles",
|
||||
]
|
||||
|
||||
const verbs = [
|
||||
"Fix",
|
||||
"Investigate",
|
||||
"Refactor",
|
||||
"Add",
|
||||
"Remove",
|
||||
"Debug",
|
||||
"Migrate",
|
||||
"Implement",
|
||||
"Review",
|
||||
"Optimize",
|
||||
"Document",
|
||||
"Rename",
|
||||
"Extract",
|
||||
"Wire up",
|
||||
"Stabilize",
|
||||
]
|
||||
|
||||
const objects = [
|
||||
"flaky retry in the session runner",
|
||||
"memory growth in the Home index",
|
||||
"i18n keys for the settings dialog",
|
||||
"the review pane remount on tab switch",
|
||||
"SQLite migration for session inbox",
|
||||
"OAuth callback handling",
|
||||
"terminal scrollback serialization",
|
||||
"Playwright visual stability probes",
|
||||
"the composer paste path",
|
||||
"provider catalog normalization",
|
||||
"the worktree preparation flow",
|
||||
"CI cache keys for bun install",
|
||||
"Markdown highlighting for large fences",
|
||||
"the permission auto-approver",
|
||||
"cursor pagination for /api/session",
|
||||
"the desktop titlebar on Windows",
|
||||
"the file tree lazy loading",
|
||||
"event replay ordering",
|
||||
"RTL layout in the sidebar",
|
||||
"unread badges for background sessions",
|
||||
]
|
||||
|
||||
const contexts = [
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
" (#{n})",
|
||||
" in packages/app",
|
||||
" in packages/core",
|
||||
" for v2",
|
||||
" before release",
|
||||
" — follow-up",
|
||||
" · src/{file}.ts",
|
||||
" after the Electron upgrade",
|
||||
" with tests",
|
||||
]
|
||||
|
||||
const files = ["controller", "index", "records", "store", "runtime", "layout", "timeline", "composer", "data", "sync"]
|
||||
|
||||
const agents = ["build", "build", "build", "build", "plan", "general"]
|
||||
|
||||
const models = [
|
||||
{ id: "claude-opus-4-6", providerID: "anthropic", variant: "default" },
|
||||
{ id: "claude-sonnet-4-6", providerID: "anthropic", variant: "default" },
|
||||
{ id: "gpt-5.3-codex", providerID: "openai", variant: "high" },
|
||||
{ id: "gemini-3-pro", providerID: "google", variant: "default" },
|
||||
]
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
export function createHomeIndexFixture(input: { count: number; now: number; directories?: number }) {
|
||||
const random = mulberry32(0x5eed_0000 + input.count)
|
||||
const directoryCount = Math.min(input.directories ?? 12, repos.length)
|
||||
const directories: HomeIndexDirectory[] = repos.slice(0, directoryCount).map((name, index) => ({
|
||||
directory: `/Users/dev/repos/${name}`,
|
||||
name,
|
||||
projectID: `prj_${hex(random, 16)}`,
|
||||
project: index < Math.max(1, Math.round(directoryCount * 0.66)),
|
||||
}))
|
||||
// Zipf-like spread: a few repositories hold most of the history.
|
||||
const weights = directories.map((_, index) => 1 / Math.pow(index + 1, 0.9))
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0)
|
||||
const cumulative = weights.map((_, index) => weights.slice(0, index + 1).reduce((sum, w) => sum + w, 0) / total)
|
||||
|
||||
// Newest first; adding the index after sorting keeps offsets strictly
|
||||
// increasing so no two sessions share an updated time.
|
||||
const offsets = Array.from({ length: input.count }, () => {
|
||||
const bucket = random()
|
||||
// 5% today, 5% yesterday, the rest skewed toward recent months over 18 months.
|
||||
if (bucket < 0.05) return Math.floor(random() * DAY * 0.9)
|
||||
if (bucket < 0.1) return DAY + Math.floor(random() * DAY * 0.9)
|
||||
return 2 * DAY + Math.floor(Math.pow(random(), 2) * 538 * DAY)
|
||||
})
|
||||
.sort((a, b) => a - b)
|
||||
.map((offset, index) => offset + index)
|
||||
|
||||
const newestFirst: HomeIndexSession[] = offsets.map((offset, index) => {
|
||||
const pick = random()
|
||||
const directory = directories[cumulative.findIndex((edge) => pick <= edge)] ?? directories[0]
|
||||
const updated = input.now - offset
|
||||
const duration = 5 * 60_000 + Math.floor(random() * 6 * 60 * 60_000)
|
||||
const tokens = {
|
||||
input: 5_000 + Math.floor(random() * 400_000),
|
||||
output: 500 + Math.floor(random() * 60_000),
|
||||
reasoning: random() < 0.6 ? Math.floor(random() * 20_000) : 0,
|
||||
cache: { read: Math.floor(random() * 900_000), write: Math.floor(random() * 120_000) },
|
||||
}
|
||||
const outcome = random() < 0.9 ? "succeeded" : random() < 0.6 ? "failed" : "interrupted"
|
||||
return {
|
||||
id: `ses_${base62(random, 26)}`,
|
||||
projectID: directory.projectID,
|
||||
...(random() < 0.97 ? { title: title(random, index) } : {}),
|
||||
agent: agents[Math.floor(random() * agents.length)],
|
||||
model: models[Math.floor(random() * models.length)],
|
||||
// USD at $3/M input, $15/M output, $0.30/M cache read, $3.75/M cache write.
|
||||
cost:
|
||||
Math.round(
|
||||
(tokens.input * 3 + tokens.output * 15 + tokens.cache.read * 0.3 + tokens.cache.write * 3.75) / 100,
|
||||
) / 10_000,
|
||||
tokens,
|
||||
outcome,
|
||||
time: {
|
||||
created: updated - duration,
|
||||
updated,
|
||||
idle: updated - Math.floor(random() * 2_000),
|
||||
...(random() < 0.8 ? { viewed: updated } : {}),
|
||||
},
|
||||
location: { directory: directory.directory },
|
||||
}
|
||||
})
|
||||
// The mock lists sessions in array order and reverses for `desc`, so keep
|
||||
// the fixture ascending by updated time like the server's index order.
|
||||
const sessions = newestFirst.toReversed()
|
||||
const newest = newestFirst[0]
|
||||
const encoded = JSON.stringify({ data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
|
||||
return {
|
||||
version: HOME_INDEX_FIXTURE_VERSION,
|
||||
count: input.count,
|
||||
now: input.now,
|
||||
directories,
|
||||
sessions,
|
||||
// Bytes the mock serves for the complete index when it fits one page.
|
||||
listBytes: Buffer.byteLength(encoded),
|
||||
expected: {
|
||||
visible: Math.min(HOME_INDEX_VISIBLE_LIMIT, input.count),
|
||||
newestID: newest.id,
|
||||
// The mock labels untitled sessions with their ID.
|
||||
newestTitle: newest.title ?? newest.id,
|
||||
perDirectory: Object.fromEntries(
|
||||
[...Map.groupBy(sessions, (session) => session.location.directory)].map(([directory, items]) => [
|
||||
directory,
|
||||
items.length,
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeIndexFixture = ReturnType<typeof createHomeIndexFixture>
|
||||
|
||||
function title(random: () => number, index: number) {
|
||||
const verb = verbs[Math.floor(random() * verbs.length)]
|
||||
const object = objects[Math.floor(random() * objects.length)]
|
||||
const context = contexts[Math.floor(random() * contexts.length)]
|
||||
.replace("{n}", String(1000 + Math.floor(random() * 45_000)))
|
||||
.replace("{file}", files[Math.floor(random() * files.length)])
|
||||
// Keep titles unique so row identity checks cannot match a sibling.
|
||||
return `${verb} ${object}${context} [${index.toString(36)}]`
|
||||
}
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
function base62(random: () => number, length: number) {
|
||||
return Array.from({ length }, () => alphabet[Math.floor(random() * alphabet.length)]).join("")
|
||||
}
|
||||
|
||||
function hex(random: () => number, length: number) {
|
||||
return Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("")
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { messages } from "./session-tab-switch.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const sessions = Array.from({ length: 8 }, (_, index) => ({
|
||||
...fixture.sessions[0],
|
||||
id: `ses_prefetch_${index}`,
|
||||
title: `Renderer review ${index}`,
|
||||
}))
|
||||
// A normal first page, not the full-history response used by the tab-switch benchmark.
|
||||
const pages = Object.fromEntries(
|
||||
sessions.map((session) => [
|
||||
session.id,
|
||||
messages[fixture.targetID].slice(-20).map((message) => ({ ...message, id: `${message.id}_${session.id}` })),
|
||||
]),
|
||||
)
|
||||
const workload = {
|
||||
sessions: sessions.length,
|
||||
messagesPerPage: 20,
|
||||
payloadBytes: Object.fromEntries(
|
||||
sessions.map((session) => [
|
||||
session.id,
|
||||
Buffer.byteLength(JSON.stringify({ data: pages[session.id].toReversed(), cursor: {} })),
|
||||
]),
|
||||
),
|
||||
partsPerPage: pages[sessions[0].id].reduce(
|
||||
(count, message) => count + (message.type === "assistant" ? message.content.length : 1),
|
||||
0,
|
||||
),
|
||||
events: 0,
|
||||
}
|
||||
type ProbeWindow = Window & { __prefetchBodies?: Record<string, number> }
|
||||
|
||||
benchmark.use({ viewport: { width: 1440, height: 900 }, video: "off", trace: "off", serviceWorkers: "block" })
|
||||
|
||||
for (const close of [false, true]) {
|
||||
benchmark(
|
||||
`inactive tab prefetch: ${close ? "close before response" : "activate after restore"}`,
|
||||
async ({ page, report }, testInfo) => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const reads: string[] = []
|
||||
const inboxReads: string[] = []
|
||||
const pending = new Set<string>()
|
||||
const concurrency = { peak: 0 }
|
||||
const mutations: string[] = []
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
page.on("response", (response) => {
|
||||
if (new URL(response.url()).pathname.startsWith("/api/") && !response.ok())
|
||||
errors.push(`HTTP ${response.status()}: ${response.url()}`)
|
||||
})
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(request.url())
|
||||
const inbox = path.match(/^\/api\/session\/([^/]+)\/inbox$/)
|
||||
if (request.method() === "GET" && inbox) inboxReads.push(inbox[1])
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
const host = window as ProbeWindow
|
||||
host.__prefetchBodies = {}
|
||||
const text = Response.prototype.text
|
||||
Response.prototype.text = async function () {
|
||||
const body = await text.call(this)
|
||||
if (this.url) {
|
||||
const path = new URL(this.url).pathname
|
||||
host.__prefetchBodies![path] = (host.__prefetchBodies![path] ?? 0) + 1
|
||||
}
|
||||
return body
|
||||
}
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
pageMessages: (id) => ({ items: pages[id] ?? [] }),
|
||||
beforeMessagesResponse: ({ sessionID }) => (sessionID === sessions[0].id ? Promise.resolve() : gate.promise),
|
||||
onMessages: ({ sessionID, phase }) => {
|
||||
if (phase === "end") return void pending.delete(sessionID)
|
||||
reads.push(sessionID)
|
||||
pending.add(sessionID)
|
||||
concurrency.peak = Math.max(concurrency.peak, pending.size)
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
await page.goto(stressSessionHref(sessions[0].id))
|
||||
await expectSessionTitle(page, sessions[0].title)
|
||||
await waitForStableTimeline(page, pages[sessions[0].id].at(-2)!.id)
|
||||
// Every inactive tab's scheduled attention request must finish. This gates on the
|
||||
// same production callback as prefetch, without a sleep or waiting for a removed read.
|
||||
await page.waitForFunction(
|
||||
(ids) => ids.every((id) => (window as ProbeWindow).__prefetchBodies![`/api/session/${id}/form`] > 0),
|
||||
sessions.slice(1).map((session) => session.id),
|
||||
)
|
||||
const speculativeReads = reads.filter((id) => id !== sessions[0].id)
|
||||
const speculativeInboxReads = inboxReads.filter((id) => id !== sessions[0].id).length
|
||||
const closed = sessions.at(-1)!
|
||||
if (close) {
|
||||
const tab = page
|
||||
.locator("[data-titlebar-tab-slot]")
|
||||
.filter({ has: page.locator(`a[href="${stressSessionHref(closed.id)}"]`) })
|
||||
await tab.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(tab).toHaveCount(0)
|
||||
}
|
||||
gate.resolve()
|
||||
await page.waitForFunction(
|
||||
(ids) => ids.every((id) => (window as ProbeWindow).__prefetchBodies![`/api/session/${id}/message`] > 0),
|
||||
reads,
|
||||
)
|
||||
await expectSessionTitle(page, sessions[0].title)
|
||||
const heap =
|
||||
process.env.OPENCODE_PERFORMANCE_MEMORY === "1"
|
||||
? await cdp.send("HeapProfiler.collectGarbage").then(() => cdp.send("Runtime.getHeapUsage"))
|
||||
: undefined
|
||||
const task =
|
||||
(await cdp.send("Performance.getMetrics")).metrics.find((metric) => metric.name === "TaskDuration")!.value *
|
||||
1000
|
||||
const before = reads.length
|
||||
const target = sessions[1]
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: pages[target.id].map((message) => message.id),
|
||||
sourceIDs: pages[sessions[0].id].map((message) => message.id),
|
||||
lastID: pages[target.id].at(-2)!.id,
|
||||
requiredPartID: `${pages[target.id].at(-1)!.id}:text:0`,
|
||||
href: stressSessionHref(target.id),
|
||||
switch: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(target.id)}"]`).click()
|
||||
await expectSessionTitle(page, target.title)
|
||||
},
|
||||
})
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-id="${pages[target.id].at(-1)!.id}:text:0"] [data-component="markdown"]`),
|
||||
).toHaveAttribute("data-markdown-ready", "")
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(mutations).toEqual([])
|
||||
expect(errors).toEqual([])
|
||||
report(
|
||||
{
|
||||
speculativeReads: speculativeReads.length,
|
||||
speculativeInboxReads,
|
||||
speculativePayloadBytes: speculativeReads.reduce((bytes, id) => bytes + workload.payloadBytes[id], 0),
|
||||
peakMessageRequests: concurrency.peak,
|
||||
closedSessionReads: close ? reads.filter((id) => id === closed.id).length : undefined,
|
||||
activationReads: reads.length - before,
|
||||
startupTaskMs: task,
|
||||
retainedHeap: heap,
|
||||
...result,
|
||||
},
|
||||
{
|
||||
...workload,
|
||||
close,
|
||||
gc: heap ? "forced retention; timing diagnostic only" : "natural; clean timing",
|
||||
transport: "playwright-route",
|
||||
browser: page.context().browser()!.version(),
|
||||
scope: "production app renderer; not total desktop RAM",
|
||||
},
|
||||
)
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath("destination.png") })
|
||||
await cdp.detach()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import config from "../playwright.config"
|
||||
|
||||
export default {
|
||||
...config,
|
||||
testDir: ".",
|
||||
testMatch: "timeline-projection-benchmark.spec.ts",
|
||||
outputDir: process.env.PROJECTION_OUTPUT,
|
||||
webServer: {
|
||||
...config.webServer,
|
||||
command: `bun run serve -- --host 127.0.0.1 --port ${process.env.PLAYWRIGHT_PORT ?? 3000} --strictPort --outDir ${process.env.PROJECTION_BUNDLE ?? "dist"}`,
|
||||
url: `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? 3000}`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
use: { ...config.use, video: "off" as const, trace: "off" as const },
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineConfig, mergeConfig } from "vite"
|
||||
import config from "../../../vite.config"
|
||||
|
||||
// Benchmark-only instrumentation. Normal production builds contain no probes.
|
||||
export default mergeConfig(
|
||||
config,
|
||||
defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "timeline-projection-measurement",
|
||||
enforce: "pre",
|
||||
transform(source, id) {
|
||||
if (!id.replaceAll("\\", "/").endsWith("/session-ui/src/timeline/projection.ts")) return
|
||||
const start = " type Turn = {"
|
||||
const end = "\n export function constructMessageRows("
|
||||
if (!source.includes(start) || !source.includes(end)) throw new Error("Projection probe boundary changed")
|
||||
return source
|
||||
.replace(
|
||||
start,
|
||||
`
|
||||
const probe = globalThis.__timelineProjectionProbe
|
||||
const started = probe ? performance.now() : 0
|
||||
try {
|
||||
${start}`,
|
||||
)
|
||||
.replace(
|
||||
end,
|
||||
`
|
||||
finally {
|
||||
if (probe) {
|
||||
probe.calls += 1
|
||||
probe.entries += messages.length
|
||||
probe.ms += performance.now() - started
|
||||
}
|
||||
}
|
||||
}
|
||||
${end}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -48,20 +48,27 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
|
||||
expect(result.samples.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
benchmark("prefetches every open session tab", async ({ page, report }) => {
|
||||
const prefetched = new Set<string>()
|
||||
benchmark("loads only the selected restored tab's transcript", async ({ page, report }) => {
|
||||
const loaded = new Set<string>()
|
||||
await mockStressTimeline(page, {
|
||||
onMessages: (input) => {
|
||||
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
|
||||
if (!input.before && input.phase === "start") loaded.add(input.sessionID)
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page, {
|
||||
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
const attention = Promise.all(
|
||||
[fixture.targetID, fixture.childID].map((id) =>
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/form`),
|
||||
),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
|
||||
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
|
||||
report({ prefetched: [...prefetched] })
|
||||
await attention
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
expect([...loaded]).toEqual([fixture.sourceID])
|
||||
report({ loaded: [...loaded] })
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ scenarios.forEach((scenario) => {
|
||||
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
expect(requests).toHaveLength(requestsBefore)
|
||||
expect(requests).toHaveLength(requestsBefore + (scenario.cache === "cold" ? 1 : 0))
|
||||
await expectReadyTimeline(page, fixture.targetID)
|
||||
report(
|
||||
{
|
||||
@@ -65,7 +65,7 @@ scenarios.forEach((scenario) => {
|
||||
browserVersion: page.context().browser()!.version(),
|
||||
serviceWorkers: "blocked",
|
||||
reviewFiles: scenario.review === "closed" ? 0 : reviewDiffs.length,
|
||||
data: "prefetched",
|
||||
data: scenario.cache === "cold" ? "on-demand" : "cached",
|
||||
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
|
||||
inputEvent: "mousedown",
|
||||
requireReadyAnswer: true,
|
||||
@@ -97,16 +97,11 @@ async function prepareSessionTabs(page: Page) {
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
// Restored tabs prefetch their data even when their transcript has never rendered.
|
||||
const prefetch = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
expect(await (await prefetch).finished()).toBeNull()
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await expectReadyTimeline(page, fixture.sourceID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${expected[fixture.targetID].answerID}"]`)).toHaveCount(0)
|
||||
expect(requests.toSorted()).toEqual([fixture.sourceID, fixture.targetID].toSorted())
|
||||
expect(requests).toEqual([fixture.sourceID])
|
||||
return requests
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,7 @@ const editPart: ToolSeed = {
|
||||
content: [{ type: "text", text: "Edited src/regression.ts" }],
|
||||
metadata: {
|
||||
files: [
|
||||
currentFile(
|
||||
"src/regression.ts",
|
||||
"export const value = 'before'\n",
|
||||
"export const value = 'after'\n",
|
||||
1,
|
||||
1,
|
||||
),
|
||||
currentFile("src/regression.ts", "export const value = 'before'\n", "export const value = 'after'\n", 1, 1),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -70,6 +64,8 @@ export async function setupTimelineBenchmark(
|
||||
eventBatch: number
|
||||
vcsDiff?: unknown[]
|
||||
turnDiffs?: unknown[]
|
||||
busy?: boolean
|
||||
historyShape?: "mixed" | "tool-heavy"
|
||||
},
|
||||
) {
|
||||
const events: EventPayload[] = []
|
||||
@@ -77,19 +73,47 @@ export async function setupTimelineBenchmark(
|
||||
const currentUserMessage = options.turnDiffs
|
||||
? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } }
|
||||
: userMessage
|
||||
const messages = [
|
||||
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index))
|
||||
.flat()
|
||||
.map((message) => {
|
||||
if (options.historyShape !== "tool-heavy" || message.type !== "assistant") return message
|
||||
return {
|
||||
...message,
|
||||
content: [
|
||||
...Array.from({ length: 6 }, (_, index) =>
|
||||
toolContent({
|
||||
id: `${message.id}:read:${index}`,
|
||||
type: "tool",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: `src/session/module-${index}.ts` },
|
||||
content: [{ type: "text", text: historicalSource(index, false) }],
|
||||
metadata: {},
|
||||
},
|
||||
time: {
|
||||
created: message.time.created,
|
||||
ran: message.time.created,
|
||||
completed: message.time.created + 100,
|
||||
},
|
||||
}),
|
||||
),
|
||||
...message.content,
|
||||
],
|
||||
}
|
||||
}),
|
||||
currentUserMessage,
|
||||
assistantMessage,
|
||||
]
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
vcsDiff: options.vcsDiff,
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
|
||||
currentUserMessage,
|
||||
assistantMessage,
|
||||
],
|
||||
}),
|
||||
sessionStatus: options.busy ? { [sessionID]: { type: "busy" } } : undefined,
|
||||
pageMessages: () => ({ items: messages }),
|
||||
events: () => events.splice(0, eventBatch),
|
||||
eventRetry: 16,
|
||||
})
|
||||
@@ -113,6 +137,11 @@ export async function setupTimelineBenchmark(
|
||||
await expectSessionTitle(page, title)
|
||||
await expectAppVisible(scroller)
|
||||
return {
|
||||
workload: {
|
||||
messages: messages.length,
|
||||
parts: messages.reduce((sum, message) => sum + (message.type === "assistant" ? message.content.length : 0), 0),
|
||||
historyBytes: Buffer.byteLength(JSON.stringify(messages)),
|
||||
},
|
||||
scroller,
|
||||
text,
|
||||
transport: {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createServer, type ServerResponse } from "node:http"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import {
|
||||
buildInitialStreamEvent,
|
||||
buildStreamDeltaEvents,
|
||||
setupTimelineBenchmark,
|
||||
textPartID,
|
||||
} from "./session-timeline-benchmark.fixture"
|
||||
|
||||
type Probe = { calls: number; entries: number; ms: number }
|
||||
type Measurement = { frames: number[]; started: number; ready: number; rowReplacements: number; stop: () => void }
|
||||
declare global {
|
||||
interface Window {
|
||||
__timelineProjectionProbe?: Probe
|
||||
__projectionMeasurement: Measurement
|
||||
}
|
||||
}
|
||||
|
||||
benchmark.use({ traceScope: "interaction" })
|
||||
|
||||
for (const scenario of [
|
||||
{ historyTurns: 40, historyShape: "mixed" },
|
||||
{ historyTurns: 320, historyShape: "mixed" },
|
||||
{ historyTurns: 320, historyShape: "tool-heavy" },
|
||||
] as const) {
|
||||
benchmark(`text projection ${scenario.historyTurns} ${scenario.historyShape}`, async ({ page, report }) => {
|
||||
benchmark.setTimeout(120_000)
|
||||
const responses = new Set<ServerResponse>()
|
||||
const source = createServer((request, response) => {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache",
|
||||
})
|
||||
response.write(
|
||||
`data: ${JSON.stringify({ id: "evt_projection_connected", type: "server.connected", data: {} })}\n\n`,
|
||||
)
|
||||
responses.add(response)
|
||||
request.on("close", () => responses.delete(response))
|
||||
})
|
||||
await new Promise<void>((resolve) => source.listen(0, "127.0.0.1", resolve))
|
||||
const address = source.address()
|
||||
if (!address || typeof address === "string") throw new Error("Missing fixture SSE address")
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const send = (events: OpenCodeEvent[]) =>
|
||||
responses.forEach((response) => events.forEach((event) => response.write(`data: ${JSON.stringify(event)}\n\n`)))
|
||||
try {
|
||||
await page.addInitScript(
|
||||
({ url, counters }) => {
|
||||
Object.assign(window, { __testSseTransport: true })
|
||||
if (counters) window.__timelineProjectionProbe = { calls: 0, entries: 0, ms: 0 }
|
||||
const fetch = window.fetch.bind(window)
|
||||
const intercept = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
return fetch(
|
||||
new URL(request.url).pathname === "/api/event" ? new Request(url, { signal: request.signal }) : request,
|
||||
)
|
||||
}
|
||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: intercept })
|
||||
},
|
||||
{ url: `http://127.0.0.1:${address.port}`, counters: process.env.PROJECTION_COUNTERS === "1" },
|
||||
)
|
||||
const initialStarted = performance.now()
|
||||
const fixture = await setupTimelineBenchmark(page, { ...scenario, busy: true, eventBatch: 1 })
|
||||
await expect.poll(() => responses.size).toBe(1)
|
||||
const deltas = buildStreamDeltaEvents(160)
|
||||
send(buildInitialStreamEvent(160))
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
await expect(fixture.text.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
const initialReadyMs = performance.now() - initialStarted
|
||||
await fixture.scrollToBottom()
|
||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
await page.evaluate(
|
||||
({ partID, counters }) => {
|
||||
const part = document.querySelector(`[data-timeline-part-id="${partID}"]`)
|
||||
const row = part?.closest("[data-timeline-key]")
|
||||
if (!row) throw new Error("Missing active row")
|
||||
if (counters) {
|
||||
if (!window.__timelineProjectionProbe?.calls)
|
||||
throw new Error("Projection instrumentation did not observe initial construction")
|
||||
window.__timelineProjectionProbe = { calls: 0, entries: 0, ms: 0 }
|
||||
}
|
||||
const measurement: Measurement = {
|
||||
frames: [],
|
||||
started: performance.now(),
|
||||
ready: 0,
|
||||
rowReplacements: 0,
|
||||
stop: () => {},
|
||||
}
|
||||
window.__projectionMeasurement = measurement
|
||||
let previous: number | undefined
|
||||
let frame = 0
|
||||
let current = row
|
||||
const sample = (now: number) => {
|
||||
if (previous !== undefined) measurement.frames.push(now - previous)
|
||||
previous = now
|
||||
const next = document.querySelector(`[data-timeline-part-id="${partID}"]`)?.closest("[data-timeline-key]")
|
||||
if (next && next !== current) {
|
||||
measurement.rowReplacements++
|
||||
current = next
|
||||
}
|
||||
const markdown = next?.querySelector('[data-component="markdown"][data-markdown-ready]')
|
||||
if (markdown?.textContent?.includes("benchmark-complete")) {
|
||||
measurement.ready = performance.now() - measurement.started
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
measurement.stop = () => cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(sample)
|
||||
},
|
||||
{ partID: textPartID, counters: process.env.PROJECTION_COUNTERS === "1" },
|
||||
)
|
||||
const emitted: number[] = []
|
||||
const started = performance.now()
|
||||
// The source clock runs in Node, never waiting for a renderer acknowledgement.
|
||||
await new Promise<void>((resolve) => {
|
||||
timer = setInterval(() => {
|
||||
const event = deltas[emitted.length]
|
||||
if (!event) throw new Error("Unexpected source overrun")
|
||||
send([event])
|
||||
emitted.push(performance.now() - started)
|
||||
if (emitted.length !== deltas.length) return
|
||||
clearInterval(timer)
|
||||
resolve()
|
||||
}, 25)
|
||||
})
|
||||
await expect(fixture.text).toContainText("benchmark-complete")
|
||||
await expect(fixture.text.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
|
||||
await page.waitForFunction(() => window.__projectionMeasurement.ready > 0)
|
||||
const metrics = await page.evaluate(() => {
|
||||
const data = window.__projectionMeasurement
|
||||
data.stop()
|
||||
return {
|
||||
frames: data.frames,
|
||||
readyMs: data.ready,
|
||||
rowReplacements: data.rowReplacements,
|
||||
projection: window.__timelineProjectionProbe ?? null,
|
||||
}
|
||||
})
|
||||
expect(emitted).toHaveLength(160)
|
||||
expect(metrics.frames.length).toBeGreaterThan(0)
|
||||
await benchmarkDiagnostics(page).stop()
|
||||
report(
|
||||
{ ...metrics, initialReadyMs, emitted },
|
||||
{
|
||||
...scenario,
|
||||
...fixture.workload,
|
||||
deltaBytes: Buffer.byteLength(JSON.stringify(deltas)),
|
||||
counters: process.env.PROJECTION_COUNTERS === "1",
|
||||
intervalMs: 25,
|
||||
deltas: 160,
|
||||
viewport: "1366x768",
|
||||
revision: process.env.PROJECTION_REVISION,
|
||||
},
|
||||
)
|
||||
if (process.env.PROJECTION_SCREENSHOT)
|
||||
await page.screenshot({
|
||||
path: `${process.env.PROJECTION_SCREENSHOT}-${scenario.historyTurns}-${scenario.historyShape}.png`,
|
||||
})
|
||||
} finally {
|
||||
clearInterval(timer)
|
||||
source.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => source.close((error) => (error ? reject(error) : resolve())))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Timeline Text Projection Benchmark
|
||||
|
||||
Run from `packages/app`, against a production bundle. No OpenCode server is needed.
|
||||
The fixture owns its HTTP event source and mocks all other API responses.
|
||||
|
||||
```sh
|
||||
bun run build -- --config e2e/performance/timeline/projection.vite.config.ts
|
||||
PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/timeline/projection.playwright.config.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line
|
||||
```
|
||||
|
||||
Set `PLAYWRIGHT_PORT` to an unused local port. Set `PROJECTION_BUNDLE` to preview a
|
||||
previously frozen bundle, `PROJECTION_OUTPUT` for test artifacts, and
|
||||
`PROJECTION_REVISION` to label results. Keep all three workloads separate:
|
||||
40 historical user/assistant pairs, 320 pairs, and 320 pairs with six additional
|
||||
completed read tools per assistant. The existing mixed fixture includes Markdown,
|
||||
reasoning, and edit/write/patch output. Results include serialized history bytes,
|
||||
message/part counts, and event bytes.
|
||||
|
||||
Each case opens the real application timeline with a busy session, waits for the
|
||||
initial Markdown to be ready, then sends the same 160 text deltas at a 25 ms source
|
||||
cadence. The Node HTTP source does not wait for renderer acknowledgements. Results
|
||||
retain every source emission time and animation-frame interval. Completion means
|
||||
the final marker is in ready Markdown and has reached an animation-frame callback;
|
||||
it does not claim a compositor presentation timestamp. Row replacements count the
|
||||
active streaming row, not normal virtualizer mounts while scrolling.
|
||||
|
||||
Use `PROJECTION_COUNTERS=1` for separate diagnostic runs. The benchmark build
|
||||
instruments the full-history constructor with its call count, input entries visited,
|
||||
and synchronous elapsed time. The probe is absent from normal builds and disabled
|
||||
in clean timing runs. `OPENCODE_PERFORMANCE_TRACE_DIR` enables the existing Chrome
|
||||
trace collector. Set `PROJECTION_SCREENSHOT` to an output path prefix for screenshots
|
||||
after timing. Do not compare diagnostic timing with clean timing.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createHomeIndexFixture, HOME_INDEX_VISIBLE_LIMIT } from "../timeline/home-session-index.fixture"
|
||||
|
||||
const now = 1_800_000_000_000
|
||||
|
||||
test("generates a deterministic index ordered like the server", () => {
|
||||
const fixture = createHomeIndexFixture({ count: 2_000, now })
|
||||
const again = createHomeIndexFixture({ count: 2_000, now })
|
||||
expect(again.sessions).toEqual(fixture.sessions)
|
||||
expect(fixture.sessions).toHaveLength(2_000)
|
||||
expect(new Set(fixture.sessions.map((session) => session.id)).size).toBe(2_000)
|
||||
const updated = fixture.sessions.map((session) => session.time.updated)
|
||||
expect(updated.every((time, index) => index === 0 || time > updated[index - 1])).toBe(true)
|
||||
expect(updated.every((time) => time <= now)).toBe(true)
|
||||
expect(fixture.sessions.every((session) => session.time.created < session.time.updated)).toBe(true)
|
||||
expect(fixture.expected.newestID).toBe(fixture.sessions[fixture.sessions.length - 1].id)
|
||||
expect(fixture.expected.visible).toBe(HOME_INDEX_VISIBLE_LIMIT)
|
||||
})
|
||||
|
||||
test("spreads sessions across several directories with a skewed head", () => {
|
||||
const fixture = createHomeIndexFixture({ count: 5_000, now })
|
||||
const counts = Object.values(fixture.expected.perDirectory)
|
||||
expect(counts.reduce((sum, count) => sum + count, 0)).toBe(5_000)
|
||||
expect(fixture.directories).toHaveLength(12)
|
||||
expect(fixture.directories.filter((entry) => entry.project)).toHaveLength(8)
|
||||
const largest = Math.max(...counts)
|
||||
expect(largest).toBeGreaterThan(5_000 * 0.15)
|
||||
expect(Math.min(...counts)).toBeGreaterThan(0)
|
||||
expect(fixture.sessions.some((session) => session.time.updated > now - 86_400_000)).toBe(true)
|
||||
// Realistic serialized rows: hundreds of bytes each, not one-character stubs.
|
||||
expect(fixture.listBytes / 5_000).toBeGreaterThan(300)
|
||||
})
|
||||
@@ -8,7 +8,8 @@ const projectID = "proj_composer_editing"
|
||||
const sessionID = "ses_composer_editing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||
test("keeps a narrow session composer contained when invoking a built-in", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -40,6 +41,8 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
|
||||
.toBe(`"${String.fromCodePoint(0x200b)}"`)
|
||||
await expectAppVisible(composer)
|
||||
await expect(page.locator('[data-slot="session-chat-panel"]')).toHaveCSS("min-width", "0px")
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
|
||||
await input.fill("keep me")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
|
||||
@@ -123,7 +123,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -131,10 +131,8 @@ async function setup(page: Page) {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -144,6 +142,6 @@ async function setup(page: Page) {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ test("renames, exports, and deletes a home session from its context menu", async
|
||||
await row.click({ button: "right", position: { x: 48, y: 12 } })
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Export…" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Delete…" })).toBeVisible()
|
||||
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
|
||||
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
|
||||
|
||||
@@ -88,13 +88,13 @@ test("renames, exports, and deletes a home session from its context menu", async
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
const download = page.waitForEvent("download")
|
||||
const exportItem = page.getByRole("menuitem", { name: "Export..." })
|
||||
const exportItem = page.getByRole("menuitem", { name: "Export…" })
|
||||
await exportItem.click()
|
||||
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
|
||||
await expect(exportItem).toBeHidden()
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Delete..." }).click()
|
||||
await page.getByRole("menuitem", { name: "Delete…" }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
|
||||
const removed = page.waitForRequest(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("inactive tabs load attention, but read transcript and inbox only on selection", async ({ page }) => {
|
||||
const reads: string[] = []
|
||||
const mutations: string[] = []
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
page.on("response", (response) => {
|
||||
if (new URL(response.url()).pathname.startsWith("/api/") && !response.ok())
|
||||
errors.push(`HTTP ${response.status()}: ${response.url()}`)
|
||||
})
|
||||
const state = { text: "Original fixture answer" }
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (!path.startsWith("/api/")) return
|
||||
if (request.method() === "GET") reads.push(path)
|
||||
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(path)
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [
|
||||
{ id: `msg_${id}_user`, type: "user", text: "Review the renderer change", time: { created: 1 } },
|
||||
{
|
||||
id: `msg_${id}_assistant`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
content: [{ type: "text", text: state.text }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID] })
|
||||
const attention = Promise.all(
|
||||
[fixture.targetID, fixture.childID].flatMap((id) =>
|
||||
["permission", "form"].map((kind) =>
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/${kind}`),
|
||||
),
|
||||
),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.sourceID}_assistant:text:0"]`)).toContainText(
|
||||
state.text,
|
||||
)
|
||||
await attention
|
||||
const child = page
|
||||
.locator("[data-titlebar-tab-slot]")
|
||||
.filter({ has: page.locator(`a[href="${stressSessionHref(fixture.childID)}"]`) })
|
||||
await child.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(child).toHaveCount(0)
|
||||
state.text = "Latest fixture answer after tab restoration"
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.targetID}_assistant:text:0"]`)).toContainText(
|
||||
state.text,
|
||||
)
|
||||
for (const id of [fixture.sourceID, fixture.targetID]) {
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
|
||||
}
|
||||
expect(
|
||||
reads.filter(
|
||||
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(mutations).toEqual([])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -107,8 +107,6 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
@@ -42,7 +42,7 @@ test("mobile project selection and drawer navigation preserve session identity",
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
|
||||
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveCount(0)
|
||||
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
|
||||
const help = drawer.getByRole("button", { name: "Help", exact: true })
|
||||
await expect(settings).toBeVisible()
|
||||
|
||||
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
|
||||
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
|
||||
@@ -42,7 +42,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await page.getByRole("button", { name: "from main", exact: true }).click()
|
||||
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
|
||||
|
||||
|
||||
@@ -9,12 +9,17 @@ const projectID = "proj_workspace_pending"
|
||||
const draftID = "draft_workspace_pending"
|
||||
const otherID = "ses_workspace_pending_other"
|
||||
const text = "Create the workspace, then explain the pending session."
|
||||
const followUp = "Then explain the setup scripts.\nInclude the install command."
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionPath = `/server/${base64Encode(server)}/session/`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
|
||||
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
|
||||
test.use({
|
||||
serviceWorkers: "block",
|
||||
viewport: { width: 1280, height: 900 },
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
})
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
@@ -24,22 +29,44 @@ for (const viewport of [
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await draftFollowUp(page)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await editor.press("Enter")
|
||||
await editor.press("ControlOrMeta+Enter")
|
||||
await page.locator('[data-component="composer"]').dispatchEvent("submit")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(editor).toBeInViewport()
|
||||
|
||||
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await expect(pending.title).toBeInViewport()
|
||||
const spinner = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
|
||||
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
|
||||
)
|
||||
await expect(spinner).toBeVisible()
|
||||
await testInfo.attach("creating-worktree", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
await expect(tab.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await tab.click()
|
||||
await expect(drawer).toBeHidden()
|
||||
await page.locator("html").evaluate((element) => {
|
||||
element.dir = "rtl"
|
||||
element.setAttribute("dir", "rtl")
|
||||
})
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await expect(pending.title).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
|
||||
expect(
|
||||
await page
|
||||
@@ -53,11 +80,14 @@ for (const viewport of [
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(pending.shimmer).toBeHidden()
|
||||
await expect(spinner).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(pending.title).toHaveText("New session")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
|
||||
@@ -88,12 +118,133 @@ for (const viewport of [
|
||||
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(spinner).toHaveCount(1)
|
||||
await expect(pending.message).toHaveCount(1)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
expect(mock.prompts).toHaveLength(1)
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect
|
||||
.poll(() => mock.prompts)
|
||||
.toEqual([
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) },
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ text: followUp }) },
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`keeps the title and message stable through worktree creation in ${direction}`, async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
await page.locator('[data-component="composer-editor"]').press("ControlOrMeta+Home")
|
||||
const title = page.locator("[data-session-title]").getByRole("heading", { level: 1 })
|
||||
const before = await title.boundingBox()
|
||||
const messageBefore = await pending.message.boundingBox()
|
||||
// Observe painted frames during the handoff, without using frame counts to wait for readiness.
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { title: string | null; message: boolean; spinner: boolean; draft: string | null }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const title = document.querySelector<HTMLElement>("[data-session-title] h1")
|
||||
const message = document.querySelector<HTMLElement>('[data-component="user-message"]')
|
||||
const spinner = document.querySelector(
|
||||
'[data-titlebar-tab-slot][data-active="true"] [data-component="session-progress-indicator-v2"]',
|
||||
)
|
||||
const editor = document.querySelector('[data-component="composer-editor"]')
|
||||
frames.push({
|
||||
title: title?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? title.textContent : null,
|
||||
message: !!message?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
|
||||
spinner: !!spinner?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
|
||||
draft: editor?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? editor.textContent : null,
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
sample()
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(title).toHaveText("Created workspace session")
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
const frames = await observation.evaluate((observation) => observation.stop())
|
||||
await observation.dispose()
|
||||
expect(frames.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
frames.filter(
|
||||
(frame) =>
|
||||
!frame.message ||
|
||||
!frame.spinner ||
|
||||
frame.draft !== followUp ||
|
||||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
),
|
||||
).toEqual([])
|
||||
const after = await title.boundingBox()
|
||||
const messageAfter = await pending.message.boundingBox()
|
||||
expect(after?.y).toBe(before?.y)
|
||||
expect(after?.height).toBe(before?.height)
|
||||
expect(messageAfter).toEqual(messageBefore)
|
||||
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
})
|
||||
}
|
||||
|
||||
for (const failure of ["worktree", "session"]) {
|
||||
test(`preserves both inputs when ${failure} creation fails`, async ({ page }) => {
|
||||
const mock = await openDraft(page, { failSessionCreate: failure === "session" })
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve(
|
||||
failure === "worktree"
|
||||
? { status: 500, json: { message: "Worktree creation failed" } }
|
||||
: { status: 200, json: { directory: workspace } },
|
||||
)
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("preserves both inputs when the initial prompt cannot be sent", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
await page.route(`**/api/session/${pending.sessionID}/prompt`, (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { message: "Prompt admission failed" },
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt", "prompt"])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("restores the original draft when worktree creation fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
@@ -104,7 +255,7 @@ test("restores the original draft when worktree creation fails", async ({ page }
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toHaveCount(0)
|
||||
@@ -144,6 +295,7 @@ test("retains the draft and reuses the created workspace after session creation
|
||||
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
|
||||
@@ -164,15 +316,16 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
await expect(pending.shimmer).toBeVisible()
|
||||
await expect(pending.shimmer).toContainText("Creating worktree")
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
@@ -182,6 +335,17 @@ test("restores the draft after closing and revisiting a pending session that fai
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
async function draftFollowUp(page: Page) {
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.pressSequentially("!")
|
||||
await expect(editor).toHaveText("!")
|
||||
await expect(editor).toHaveAttribute("dir", "auto")
|
||||
await editor.fill("")
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), followUp)
|
||||
await editor.press("ControlOrMeta+V")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
}
|
||||
|
||||
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
@@ -254,6 +418,24 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
headers,
|
||||
})
|
||||
})
|
||||
await page.route("**/api/agent?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, otherID, server }) => {
|
||||
localStorage.setItem(
|
||||
@@ -276,8 +458,8 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
|
||||
await page.goto(draftPath)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
return { worktree, worktreeRequests, calls, creates, prompts }
|
||||
}
|
||||
@@ -292,7 +474,11 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
const preparing = page.locator('[data-component="session-preparing"]')
|
||||
const message = page.locator('[data-component="user-message"]')
|
||||
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
|
||||
const title = preparing.getByRole("heading", { level: 1 })
|
||||
await expect(preparing).toBeVisible()
|
||||
await expect(title).toHaveText("New session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
@@ -304,5 +490,5 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
return { url, sessionID, messageID, message, shimmer }
|
||||
return { url, sessionID, messageID, message, shimmer, title }
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "C:/Projects/extensions-demo"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const session = {
|
||||
id: "ses_project_extensions",
|
||||
title: "Existing session",
|
||||
directory,
|
||||
projectID: "proj_extensions_demo",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
|
||||
|
||||
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: session.projectID,
|
||||
canonical: directory,
|
||||
name: "Extensions demo",
|
||||
vcs: "git",
|
||||
time: session.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: session.id, directory },
|
||||
)
|
||||
const href = `/server/${base64Encode(server)}/session/${session.id}`
|
||||
await page.goto(href)
|
||||
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByText("Extensions demo", { exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
|
||||
|
||||
const globalPlugins = Promise.withResolvers<void>()
|
||||
const projectPlugins = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
async (route) => {
|
||||
const project = new URL(route.request().url()).searchParams.get("location[directory]")
|
||||
await (project ? projectPlugins : globalPlugins).promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
location: project ? { directory: project } : {},
|
||||
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
|
||||
id,
|
||||
source: { type: "package", package: id },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
})),
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
const requested = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
|
||||
})
|
||||
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await requested
|
||||
await expect(page).toHaveURL(href)
|
||||
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
|
||||
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
globalPlugins.resolve()
|
||||
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
|
||||
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
|
||||
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
projectPlugins.resolve()
|
||||
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
|
||||
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(href)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
|
||||
})
|
||||
@@ -6,8 +6,9 @@ const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${p
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
const childB = { ...session("ses_server_b_child", sessionB.directory, "Server B subagent"), parentID: sessionB.id }
|
||||
|
||||
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
|
||||
test("tab busy indicator reflects activity in the tab session family", async ({ page }, info) => {
|
||||
await mockServers(page)
|
||||
await page.addInitScript(
|
||||
({ serverA, serverB, sessionA, sessionB }) => {
|
||||
@@ -25,13 +26,13 @@ test("tab busy indicator reflects the tab server's own session status", async ({
|
||||
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
await page.goto(hrefB)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
|
||||
// Session B is busy on server B while server A stays the active server, so the
|
||||
// busy indicator must come from the tab server's status, not the active server's.
|
||||
// The parent is idle, but its tab remains active while the background child runs.
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await tabB.screenshot({ path: info.outputPath("subagent-tab-activity.png") })
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
|
||||
@@ -60,8 +61,12 @@ async function mockServers(page: Page) {
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
return json(route, { data: url.origin === serverB ? { [childB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, {
|
||||
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
|
||||
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
@@ -95,11 +93,18 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
||||
const openFileTab = panel.getByRole("tab", { name: "Open file" })
|
||||
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
|
||||
await expect(openFileTab).toHaveAttribute("data-selected", "")
|
||||
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
|
||||
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
|
||||
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
|
||||
await expect(openFileTabClose).toHaveCSS("opacity", "1")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await expect(sidebar).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||
await expect(openFileTabClose).toHaveCSS("opacity", "0")
|
||||
await expect(sidebar).toBeHidden()
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
|
||||
@@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await selectFile(page, "alpha.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
|
||||
@@ -18,37 +18,72 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The v2 review pane's diff data is workspace-scoped: switching between session
|
||||
// tabs in the same workspace must update its parameters reactively instead of
|
||||
// tearing the pane down and remounting it (which flickers).
|
||||
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
|
||||
// The review pane's data is workspace-scoped, but visibility belongs to each
|
||||
// session tab. Switching tabs must keep the pane mounted without opening it.
|
||||
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => getComputedStyle(element).transitionDuration),
|
||||
)
|
||||
.toContain("0.24s")
|
||||
const reviewTab = page.locator("#session-side-panel-review-tab")
|
||||
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
||||
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
|
||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
await expect(review).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => getComputedStyle(element).transitionDuration),
|
||||
)
|
||||
.toBe("0s")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "520px")
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
({ key }) => {
|
||||
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
|
||||
return panes[key]?.review
|
||||
},
|
||||
{ key: `${server}\n${sessionHref(sessionA)}` },
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
|
||||
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
@@ -102,7 +137,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
({ directory, server, sessions, panes }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -114,8 +149,19 @@ async function setup(page: Page) {
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
|
||||
}
|
||||
},
|
||||
{
|
||||
directory,
|
||||
server,
|
||||
sessions: [sessionA, sessionB],
|
||||
panes: {
|
||||
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
|
||||
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
|
||||
},
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ tabKey, server, sessionID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, showStatus: true } }),
|
||||
)
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
const more = header.getByRole("button", { name: "More options", exact: true })
|
||||
const review = header.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const details = header.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
await expect(review).toBeVisible()
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all(
|
||||
[header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()),
|
||||
)
|
||||
const [title, menu, sidebar, summary] = boxes
|
||||
if (!title || !menu || !sidebar || !summary) return false
|
||||
return direction === "ltr"
|
||||
? Math.abs(title.x + title.width - menu.x) <= 1 &&
|
||||
menu.x + menu.width <= summary.x &&
|
||||
summary.x + summary.width <= sidebar.x
|
||||
: Math.abs(menu.x + menu.width - title.x) <= 1 &&
|
||||
sidebar.x + sidebar.width <= summary.x &&
|
||||
summary.x + summary.width <= menu.x
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
|
||||
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
|
||||
await expect(mcp).toHaveAttribute("aria-selected", "true")
|
||||
await plugins.click()
|
||||
await expect(plugins).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(mcp).toBeHidden()
|
||||
})
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => {
|
||||
const directory = "/projects/deleted"
|
||||
const destination = "/projects/restored"
|
||||
const sessionID = "ses_missing_location"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
|
||||
const events: OpenCodeEvent[] = []
|
||||
const moves: unknown[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: destination,
|
||||
project: { ...fixture.project, worktree: destination },
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
events: () => events.splice(0),
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("status")).toContainText("Session location unavailable")
|
||||
await expect(page.getByRole("status")).toContainText(directory)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
const choose = page.getByRole("button", { name: "Choose directory", exact: true })
|
||||
await expect(choose).toBeEnabled()
|
||||
await choose.click()
|
||||
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([])
|
||||
|
||||
// The server can still reject a destination after it was selected in the picker.
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await choose.press("Enter")
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
|
||||
await expect(choose).toBeEnabled()
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
await expect(choose).toBeFocused()
|
||||
expect(moves).toEqual([{ directory: destination }])
|
||||
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
session.directory = destination
|
||||
events.push({
|
||||
id: "evt_location_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await choose.click()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
expect(moves).toEqual([{ directory: destination }, { directory: destination }])
|
||||
})
|
||||
|
||||
for (const create of [false, true]) {
|
||||
test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const canonical = "/projects/repository"
|
||||
const directory = "/projects/deleted-worktree"
|
||||
const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree"
|
||||
const sessionID = "ses_worktree_recovery"
|
||||
const events: OpenCodeEvent[] = []
|
||||
const requests: { operation: string; body: unknown }[] = []
|
||||
let listing = Promise.withResolvers<void>()
|
||||
let listingRequested = Promise.withResolvers<void>()
|
||||
const moving = Promise.withResolvers<void>()
|
||||
const moveRequested = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: canonical,
|
||||
project: { ...fixture.project, worktree: canonical },
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
if (url.searchParams.get("location[directory]") === directory)
|
||||
return route.fulfill({ status: 500, body: "", headers })
|
||||
if (url.pathname === `/api/worktree/${fixture.project.id}`) {
|
||||
if (route.request().method() === "GET") {
|
||||
listingRequested.resolve()
|
||||
await listing.promise
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ directory: canonical, strategy: null },
|
||||
{ directory: "/projects/existing-worktree", strategy: "git" },
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
directory: `/projects/worktree-${index}`,
|
||||
strategy: "git",
|
||||
})),
|
||||
],
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (route.request().method() === "POST") {
|
||||
requests.push({ operation: "create", body: route.request().postDataJSON() })
|
||||
return route.fulfill({
|
||||
json: { directory: destination, name: "new-worktree", branch: "new-worktree" },
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") {
|
||||
requests.push({ operation: "move", body: route.request().postDataJSON() })
|
||||
moveRequested.resolve()
|
||||
await moving.promise
|
||||
events.push({
|
||||
id: "evt_worktree_recovered",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
return route.fulfill({ status: 204, headers })
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
|
||||
await expect(existing).toBeVisible()
|
||||
await expect(existing).toHaveCSS("height", "28px")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menu")).toHaveCount(0)
|
||||
listing = Promise.withResolvers<void>()
|
||||
listingRequested = Promise.withResolvers<void>()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await listingRequested.promise
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click()
|
||||
await moveRequested.promise
|
||||
await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled()
|
||||
moving.resolve()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
expect(requests).toEqual([
|
||||
...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []),
|
||||
{ operation: "move", body: { directory: destination } },
|
||||
])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
for (const endpoint of ["/api/location", "/api/agent"]) {
|
||||
for (const recover of [false, true]) {
|
||||
test(`keeps the composer when ${endpoint} ${recover ? "recovers on retry" : "fails"}`, async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/working-tree"
|
||||
const sessionID = "ses_location_sync_failure"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory, title: "Workspace sync" }],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep working in this worktree", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
let requests = 0
|
||||
await page.route("**/api/**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname !== endpoint || url.searchParams.get("location[directory]") !== directory)
|
||||
return route.fallback()
|
||||
requests++
|
||||
if (recover && requests > 1) return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
const failure = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === endpoint && response.status() === 500,
|
||||
)
|
||||
const settled = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === endpoint &&
|
||||
url.searchParams.get("location[directory]") === directory &&
|
||||
(recover ? response.ok() : requests === 3 && response.status() === 500)
|
||||
)
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await failure
|
||||
await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible()
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Continue after reconnecting")
|
||||
await expect(prompt).toHaveText("Continue after reconnecting")
|
||||
await settled
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Continue after reconnecting")
|
||||
expect(requests).toBe(recover ? 2 : 3)
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(recovery).toEqual([])
|
||||
await page.screenshot({ path: test.info().outputPath("location-sync.png") })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/old-tree"
|
||||
const destination = "/projects/current-tree"
|
||||
const sessionID = "ses_location_moved_while_loading"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Follow the session move", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory) {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await requested.promise
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Keep this draft")
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await transport.waitForConnection()
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
)
|
||||
})
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
id: "evt_location_moved_while_loading",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await resolved
|
||||
const delayed = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory && response.ok()
|
||||
})
|
||||
release.resolve()
|
||||
await delayed
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Keep this draft")
|
||||
await expect(page.getByText("Follow the session move", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-type="location-switched"]').getByText(destination, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(recovery).toEqual([])
|
||||
})
|
||||
|
||||
test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/before-reconnect"
|
||||
const destination = "/projects/after-reconnect"
|
||||
const sessionID = "ses_location_reconnect"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Reconnected session" }
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Resume in the current worktree", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Draft before disconnect")
|
||||
const connection = await transport.waitForConnection()
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok()
|
||||
)
|
||||
})
|
||||
session.directory = destination
|
||||
await transport.close()
|
||||
await transport.waitForConnection({ after: connection.id })
|
||||
await resolved
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Draft before disconnect")
|
||||
await expect(page.getByText("Resume in the current worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(recovery).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores an old failed location read after reconnecting", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/reconnected-tree"
|
||||
const sessionID = "ses_location_stale_response"
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [{ id: sessionID, projectID: fixture.project.id, directory }],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
let requests = 0
|
||||
await page.route("**/api/location?**", async (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
requests++
|
||||
if (requests > 1) return route.fallback()
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
body: "",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await requested.promise
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Keep typing here")
|
||||
const connection = await transport.waitForConnection()
|
||||
const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${sessionID}`)
|
||||
const resolved = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return (
|
||||
url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory && response.ok()
|
||||
)
|
||||
})
|
||||
await transport.close()
|
||||
await transport.waitForConnection({ after: connection.id })
|
||||
await metadata
|
||||
release.resolve()
|
||||
await resolved
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Keep typing here")
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0)
|
||||
expect(requests).toBe(2)
|
||||
expect(recovery).toEqual([])
|
||||
})
|
||||
|
||||
function recoveryRequests(page: Page) {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
|
||||
})
|
||||
return requests
|
||||
}
|
||||
@@ -308,8 +308,8 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toHaveText(/^Used\s*2 Read, Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Read, Grep")
|
||||
await expect(tools).toHaveText(/^Used\s*1 Read, 1 Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Read, 1 Grep")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
|
||||
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*1 Read, 1 Grep$/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/^Used\s*2 Read, Grep$/,
|
||||
/^Used\s*1 Read, 1 Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
])
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 1 Read, 1 Glob, 1 Grep, 1 List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
|
||||
expect(labels).toEqual(["Used 1 Read, 1 Glob, 1 Grep, 1 List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ test("transitions a streaming shell from writing through command execution", asy
|
||||
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(titleShimmer).toHaveAttribute("aria-label", "Shell")
|
||||
await expect(titleShimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(subtitle).toHaveText("Writing command...")
|
||||
await expect(subtitle).toHaveText("Writing command…")
|
||||
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
|
||||
@@ -75,7 +75,7 @@ test("transitions a streaming shell from writing through command execution", asy
|
||||
await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input }))
|
||||
await expect(titleShimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(subtitle).toHaveText(command)
|
||||
await expect(tool).not.toContainText("Writing command...")
|
||||
await expect(tool).not.toContainText("Writing command…")
|
||||
|
||||
await timeline.send(
|
||||
toolCalled({
|
||||
@@ -100,7 +100,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(tool).not.toContainText("Writing command...")
|
||||
await expect(tool).not.toContainText("Writing command…")
|
||||
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
|
||||
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
|
||||
|
||||
@@ -16,9 +16,9 @@ for (const locale of ["de", "ar"] as const) {
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(`2 ${names}`)
|
||||
const names = locale === "de" ? "1 Lesen, 1 Glob" : "1 \u0642\u0631\u0627\u0621\u0629, 1 Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,10 +45,10 @@ test("expands a mixed collapsed tool stack without expanding its individual call
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
|
||||
const summary = group.getByRole("button", { name: "Used 2 Shell, 1 Agent, 1 Patch", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, Patch")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Shell, 1 Agent, 1 Patch")
|
||||
await expect(summary.locator('[data-component="tag"]')).toHaveCount(0)
|
||||
await summary.click()
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -75,8 +75,8 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Patch, Read")
|
||||
await expect(group.getByRole("button", { name: "Used 1 Patch, 1 Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Patch, 1 Read")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
|
||||
@@ -114,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await group.getByRole("button", { name: "Used 1 Shell, 1 Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -129,7 +129,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
|
||||
await expect(group.getByRole("button", { name: "Used 1 Shell, 2 Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
@@ -162,8 +162,8 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
|
||||
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("2 Glob, Grep")
|
||||
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
directory,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
status,
|
||||
stepStarted,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`shows Working between busy and reasoning states at ${width}px`, async ({ page }, testInfo) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage()],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toHaveCount(1)
|
||||
await expect(working).toHaveRole("status")
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeInViewport()
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveCSS("line-height", "16px")
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath(`working-${width}.png`) })
|
||||
|
||||
await timeline.send(stepStarted(assistantMessage([], { completed: false })))
|
||||
await expect(working).toBeVisible()
|
||||
const id = `prt_working_reasoning_${width}`
|
||||
await timeline.send(partUpdated(reasoningPart(id, "")))
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(reasoningPart(id, "The inspection is complete.")))
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeInViewport()
|
||||
|
||||
await timeline.send(status("idle"))
|
||||
await expect(working).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
const id = `prt_working_${name}`
|
||||
const input =
|
||||
name === "shell"
|
||||
? { command: "printf ready" }
|
||||
: name === "patch"
|
||||
? { patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch" }
|
||||
: { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." }
|
||||
await timeline.send(partUpdated(toolPart(id, name, "streaming", input)))
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(tool).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
const metadata =
|
||||
name === "patch"
|
||||
? {
|
||||
files: [
|
||||
{
|
||||
file: "src/working.ts",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: "@@ -0,0 +1 @@\n+export const ready = true",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}
|
||||
await timeline.send(partUpdated(toolPart(id, name, "running", input, { metadata })))
|
||||
await expect(tool).toContainText(
|
||||
name === "shell" ? "printf ready" : name === "patch" ? "working.ts" : "Inspect working indicator",
|
||||
)
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
|
||||
if (name === "shell") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(
|
||||
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
|
||||
).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group).toBeVisible()
|
||||
}
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeVisible()
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of ["read", "shell", "subagent"] as const) {
|
||||
test(`keeps Working for grouped ${name} regardless of disclosure`, async ({ page }, testInfo) => {
|
||||
await setupTimeline(page, {
|
||||
viewport: { width: name === "shell" ? 390 : 1400, height: 900 },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart("prt_grouped_previous", "read", "completed", { filePath: "package.json" }),
|
||||
toolPart(
|
||||
"prt_grouped_active",
|
||||
name,
|
||||
"running",
|
||||
name === "shell"
|
||||
? { command: "sleep 10" }
|
||||
: name === "subagent"
|
||||
? { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." }
|
||||
: { filePath: "src/working.ts" },
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_grouped_previous,prt_grouped_active")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeInViewport()
|
||||
if (name !== "read") {
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
await expect(hint).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
|
||||
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
|
||||
}
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
if (name === "subagent") await expect(group.getByText("Inspect the timeline", { exact: true })).toBeVisible()
|
||||
if (name !== "subagent")
|
||||
await expect(group.locator('[data-component="text-shimmer"][data-active="true"]')).toBeVisible()
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
for (const grouped of [false, true]) {
|
||||
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation for Working`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
settings: { shellToolPartsExpanded: !grouped },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart("prt_background_previous", "shell", "completed", { command: "echo ready" }),
|
||||
toolPart(
|
||||
"prt_background_active",
|
||||
"shell",
|
||||
"completed",
|
||||
{ command: "sleep 10" },
|
||||
{
|
||||
metadata: { shellID: "sh_working_background", status: "running" },
|
||||
},
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
if (!grouped) {
|
||||
await expect(page.locator('[data-timeline-part-id="prt_background_active"]')).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("replaces Working with Retry and restores it on recovery", async ({ page }) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(status("retry"))
|
||||
const retry = page.locator('[data-timeline-row="Retry"]')
|
||||
await expect(retry).toContainText("Rate limited")
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
await timeline.send(stepStarted(assistant))
|
||||
await expect(retry).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
test("hides Working while assistant text streams", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send({
|
||||
id: "evt_working_text_started",
|
||||
type: "session.text.started",
|
||||
created: 1700000002000,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: 0, version: 1 },
|
||||
data: { sessionID, assistantMessageID: assistantID, ordinal: 0 },
|
||||
})
|
||||
await timeline.send({
|
||||
id: "evt_working_text_delta",
|
||||
type: "session.text.delta",
|
||||
created: 1700000002001,
|
||||
location: { directory },
|
||||
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta: "The response is streaming." },
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toContainText(
|
||||
"The response is streaming.",
|
||||
)
|
||||
await expect(working).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const failed of [false, true]) {
|
||||
test(`shows Working before prompt admission or execution events${failed ? " after an error" : ""}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
...(failed
|
||||
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
|
||||
: []),
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Check the working indicator immediately.")
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
const request = await requested
|
||||
expect(request.postDataJSON()).toMatchObject({ text: "Check the working indicator immediately." })
|
||||
await expect(working).toHaveRole("status")
|
||||
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
|
||||
await expect(working).toBeInViewport()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -45,15 +45,15 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
|
||||
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("heading", { name: "Workspaces", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
|
||||
await expect(settings.getByText("No workspaces", { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText("No worktrees", { exact: true })).toHaveCount(0)
|
||||
|
||||
inventory.resolve()
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("12 workspaces", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("12 worktrees", { exact: true })).toBeVisible()
|
||||
sessions.resolve()
|
||||
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -63,46 +63,25 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
await route.fallback()
|
||||
})
|
||||
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
|
||||
refresh.resolve()
|
||||
})
|
||||
|
||||
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
|
||||
test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
const mcps = Promise.withResolvers<void>()
|
||||
const plugins = Promise.withResolvers<void>()
|
||||
await page.route("**/api/mcp", async (route) => {
|
||||
await mcps.promise
|
||||
await route.fulfill({
|
||||
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
|
||||
})
|
||||
})
|
||||
await page.route("**/api/plugin", async (route) => {
|
||||
await plugins.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
location: { directory },
|
||||
data: [
|
||||
{
|
||||
id: "demo-plugin",
|
||||
source: { type: "package", package: "demo-plugin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
|
||||
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
plugins.resolve()
|
||||
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
|
||||
mcps.resolve()
|
||||
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
|
||||
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
|
||||
@@ -110,7 +89,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
|
||||
|
||||
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
|
||||
const list = settings.locator('[data-component="settings-list"]')
|
||||
await expect(list).toHaveCSS("max-height", "none")
|
||||
|
||||
@@ -22,8 +22,7 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
|
||||
|
||||
const titlebarRight = page.locator("#opencode-titlebar-right")
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("returns to the parent session with Escape", async ({ page }) => {
|
||||
|
||||
@@ -9,6 +9,32 @@ const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
const unresolvedSessionID = "ses_tab_unresolved"
|
||||
|
||||
test("new session tab hugs its content", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "draft", server, directory, draftID: "draft_tab_width" },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: sessionA.id, directory: sessionA.directory },
|
||||
)
|
||||
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
|
||||
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
|
||||
await expect(draftTab).toContainText("New session")
|
||||
const width = (await draftTab.boundingBox())?.width ?? 0
|
||||
expect(width).toBeGreaterThan(100)
|
||||
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
|
||||
})
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -121,7 +147,10 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ appearance: { tabLayout: "vertical", showProjectName: true }, general: { showStatus: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
@@ -144,7 +173,26 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabA).toContainText(sessionA.title)
|
||||
await expect(tabB).toContainText(sessionB.title)
|
||||
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
|
||||
await expect(sidebar.getByRole("button", { name: "Home", exact: true })).toHaveText("Home")
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await sidebar.boundingBox()
|
||||
const button = await status.boundingBox()
|
||||
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await sidebar.getByRole("button", { name: "New session" }).boundingBox()
|
||||
const tab = await tabA.boundingBox()
|
||||
return !!button && !!tab && button.y + button.height < tab.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
|
||||
const handle = sidebar.locator('[data-component="resize-handle"]')
|
||||
@@ -171,7 +219,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
test("appearance experimental setting switches tab orientation", async ({ page }) => {
|
||||
test("appearance experimental settings control vertical tab details", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA }) => {
|
||||
@@ -203,6 +251,12 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
await expect(layout).toContainText("Vertical")
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
const projectNames = page.locator('[data-slot="vertical-tabs-sidebar"] [data-slot="tab-project"]')
|
||||
await expect(projectNames).toHaveCount(0)
|
||||
const projectNameSwitch = settings.getByRole("switch", { name: "Show project names", exact: true })
|
||||
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
|
||||
await expect(projectNameSwitch).toBeChecked()
|
||||
await expect(projectNames).toHaveText(["tab-project"])
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
|
||||
|
||||
await page.setViewportSize({ width: 920, height: 720 })
|
||||
@@ -226,6 +280,9 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
await page.reload()
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
|
||||
"tab-project",
|
||||
])
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
|
||||
@@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
return page.addInitScript(
|
||||
({ terminalKey, ptyID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs.panes",
|
||||
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
localStorage.setItem(
|
||||
terminalKey,
|
||||
JSON.stringify({
|
||||
@@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ terminalKey: terminalStorageKey(), ptyID },
|
||||
{
|
||||
terminalKey: terminalStorageKey(),
|
||||
ptyID,
|
||||
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
server,
|
||||
sessionID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// Terminals are workspace-scoped: switching between session tabs in the same
|
||||
// workspace must keep the terminal mounted and its PTY connection open instead
|
||||
// of tearing it down and reconnecting.
|
||||
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
|
||||
// Terminal processes are workspace-scoped, but panel visibility belongs to each
|
||||
// session tab. Switching tabs must keep the PTY alive without opening its panel.
|
||||
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
|
||||
const connections = await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
@@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
const terminalPanel = page.locator('[data-component="terminal-panel"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
await expect.poll(() => connections.length).toBe(1)
|
||||
const connection = new URL(connections[0]!)
|
||||
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||
@@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "180px")
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
@@ -120,7 +133,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
({ directory, server, sessions, panes }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -132,8 +145,20 @@ async function setup(page: Page) {
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
|
||||
}
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
|
||||
},
|
||||
{
|
||||
directory,
|
||||
server,
|
||||
sessions: [sessionA, sessionB],
|
||||
panes: {
|
||||
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
|
||||
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
|
||||
},
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
return connections
|
||||
}
|
||||
|
||||
@@ -75,11 +75,12 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expectBackground(view.send, "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(
|
||||
await expectToken(
|
||||
message,
|
||||
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
|
||||
"background-color",
|
||||
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
|
||||
)
|
||||
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,8 +134,8 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
@@ -245,3 +246,16 @@ async function expectBackground(element: Locator, token: string, property = "bac
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
||||
}
|
||||
|
||||
async function expectToken(element: Locator, property: string, token: string) {
|
||||
const color = await element.evaluate((element, token) => {
|
||||
const probe = document.createElement("span")
|
||||
probe.hidden = true
|
||||
probe.style.color = `var(${token})`
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return color
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, color)
|
||||
}
|
||||
|
||||
@@ -609,9 +609,12 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
...(typeof session.outcome === "string" ? { outcome: session.outcome } : {}),
|
||||
time: {
|
||||
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
||||
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
||||
...("idle" in time && typeof time.idle === "number" ? { idle: time.idle } : {}),
|
||||
...("viewed" in time && typeof time.viewed === "number" ? { viewed: time.viewed } : {}),
|
||||
...(session.time && typeof session.time === "object" && "archived" in session.time
|
||||
? { archived: session.time.archived }
|
||||
: {}),
|
||||
|
||||
@@ -11,12 +11,6 @@ beforeAll(async () => {
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
use: () => undefined,
|
||||
provider: () => undefined,
|
||||
}),
|
||||
}))
|
||||
const mod = await import("./comments")
|
||||
createCommentSessionForTest = mod.createCommentSessionForTest
|
||||
})
|
||||
|
||||
@@ -10,14 +10,9 @@ import { createScopedCache } from "@/runtime/server/scoped-cache"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { CommentStore, type LineComment } from "./schema"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
time: number
|
||||
}
|
||||
export type { LineComment } from "./schema"
|
||||
|
||||
type CommentFocus = { file: string; id: string }
|
||||
|
||||
@@ -37,10 +32,6 @@ function decodeSessionKey(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type CommentStore = {
|
||||
comments: Record<string, LineComment[]>
|
||||
}
|
||||
|
||||
function aggregate(comments: Record<string, LineComment[]>) {
|
||||
return Object.keys(comments)
|
||||
.flatMap((file) => comments[file] ?? [])
|
||||
@@ -179,12 +170,9 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
|
||||
}
|
||||
|
||||
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "comments"),
|
||||
createStore<CommentStore>({
|
||||
comments: {},
|
||||
}),
|
||||
)
|
||||
const [store, setStore, _, ready] = persisted(Persist.serverScoped(scope, dir, id, "comments"), CommentStore, {
|
||||
comments: {},
|
||||
})
|
||||
const session = createCommentSessionState(store, setStore)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { batch, untrack, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
import type {
|
||||
ComposerAgentPart,
|
||||
@@ -23,43 +23,47 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
|
||||
return typeof value === "function" ? value() : value
|
||||
}
|
||||
const setStore = () => tuple()[1]
|
||||
const clearRetry = () => setStore()("retry", undefined)
|
||||
const clearRetry = () => {
|
||||
if (untrack(() => store().retry) !== undefined) setStore()("retry", undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return store()
|
||||
},
|
||||
setPrompt(prompt: ComposerPrompt, cursor?: number) {
|
||||
batch(() => {
|
||||
setStore()("prompt", prompt)
|
||||
if (cursor !== undefined) setStore()("cursor", cursor)
|
||||
clearRetry()
|
||||
})
|
||||
// Persisted setters encode on every call, even inside a reactive batch.
|
||||
batch(() => setStore()({ prompt, ...(cursor !== undefined ? { cursor } : {}), retry: undefined }))
|
||||
},
|
||||
setCursor(cursor: number) {
|
||||
if (untrack(() => store().cursor) === cursor) return
|
||||
setStore()("cursor", cursor)
|
||||
},
|
||||
setMode(mode: "normal" | "shell") {
|
||||
setStore()("mode", mode)
|
||||
clearRetry()
|
||||
if (untrack(() => store().mode === mode && store().retry === undefined)) return
|
||||
setStore()({ mode, retry: undefined })
|
||||
},
|
||||
setText(content: string) {
|
||||
batch(() => {
|
||||
setStore()("prompt", (prompt) => [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
...prompt.filter((part) => part.type === "image"),
|
||||
])
|
||||
setStore()("cursor", content.length)
|
||||
clearRetry()
|
||||
})
|
||||
batch(() =>
|
||||
setStore()((state) => ({
|
||||
prompt: [
|
||||
{ type: "text", content, start: 0, end: content.length },
|
||||
...state.prompt.filter((part) => part.type === "image"),
|
||||
],
|
||||
cursor: content.length,
|
||||
retry: undefined,
|
||||
})),
|
||||
)
|
||||
},
|
||||
addText(content: string) {
|
||||
const cursor = store().cursor ?? promptLength(store().prompt)
|
||||
batch(() => {
|
||||
setStore()("prompt", (prompt) => insertText(prompt, cursor, content))
|
||||
setStore()("cursor", cursor + content.length)
|
||||
clearRetry()
|
||||
})
|
||||
batch(() =>
|
||||
setStore()((state) => ({
|
||||
prompt: insertText(state.prompt, cursor, content),
|
||||
cursor: cursor + content.length,
|
||||
retry: undefined,
|
||||
})),
|
||||
)
|
||||
},
|
||||
removeContext(key: string) {
|
||||
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[data-component="composer-editor"]:empty::before {
|
||||
content: "\200B";
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
event.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
<Show when={state.popover.type !== "closed"}>
|
||||
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
|
||||
<ComposerEditorPopover
|
||||
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
|
||||
items={props.controller.suggestions()}
|
||||
@@ -114,10 +114,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
<form
|
||||
data-component="composer"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01": props.borderUnderlay,
|
||||
"bg-v2-background-bg-base": !props.borderUnderlay,
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
@@ -187,7 +185,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (props.controller.onKeyDown(event)) return
|
||||
if (!view.draftOnly && props.controller.onKeyDown(event)) return
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
|
||||
if (view.submit.queue?.editFirst()) event.preventDefault()
|
||||
@@ -240,7 +238,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
style={buttons()}
|
||||
>
|
||||
<ComposerEditorAddMenu
|
||||
disabled={state.mode === "shell"}
|
||||
disabled={view.draftOnly || state.mode === "shell"}
|
||||
title={i18n.t("ui.promptInput.add")}
|
||||
keybind={props.attachKeybind ?? ["Mod", "U"]}
|
||||
attachLabel={i18n.t("ui.promptInput.attachments")}
|
||||
@@ -557,7 +555,10 @@ export function ComposerEditorAddMenu(props: {
|
||||
aria-label={props.title}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content style={{ "min-width": "180px" }}>
|
||||
<Menu.Content
|
||||
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
|
||||
style={{ "min-width": "180px" }}
|
||||
>
|
||||
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
|
||||
{props.attachLabel}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ComposerSelectControl = {
|
||||
}
|
||||
|
||||
export type ComposerEditorView = {
|
||||
draftOnly?: boolean
|
||||
placeholder?: Accessor<string>
|
||||
add?: {
|
||||
onAttach: () => void
|
||||
@@ -332,6 +333,7 @@ export function createComposerEditor(input: {
|
||||
draft.removeAttachment(id)
|
||||
},
|
||||
canSubmit() {
|
||||
if (input.view.draftOnly) return false
|
||||
const persisted = draft.state
|
||||
if (state.mode === "shell") {
|
||||
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
|
||||
@@ -347,6 +349,7 @@ export function createComposerEditor(input: {
|
||||
restoreFocus,
|
||||
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
|
||||
if (prompt) draft.setPrompt(prompt, cursor)
|
||||
if (input.view.draftOnly) return
|
||||
dispatch({ type: "input.changed", value, persist: !prompt })
|
||||
},
|
||||
onCursor(cursor: number) {
|
||||
@@ -362,6 +365,7 @@ export function createComposerEditor(input: {
|
||||
dispatch({ type: "mode.shell" })
|
||||
},
|
||||
submit(options?: { alternate?: boolean }) {
|
||||
if (input.view.draftOnly) return
|
||||
input.view.submit.onSubmit(options)
|
||||
dispatch({ type: "popover.close" })
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { upgradeHistoryState } from "./store"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
@@ -58,7 +60,11 @@ describe("Composer history", () => {
|
||||
})
|
||||
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
|
||||
entries: [text("stored")],
|
||||
}),
|
||||
).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import type { SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
|
||||
|
||||
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
export type PromptHistoryComment = {
|
||||
id: string
|
||||
path: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
time: number
|
||||
origin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type PromptHistoryEntry = {
|
||||
prompt: Prompt
|
||||
comments: PromptHistoryComment[]
|
||||
}
|
||||
|
||||
export type PromptHistoryStoredEntry = PromptHistoryEntry
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
@@ -8,28 +8,14 @@ import {
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
import { clonePrompt } from "../prompt-parts"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
export function upgradeHistoryState(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value
|
||||
const entries = value.entries
|
||||
if (!Array.isArray(entries)) return value
|
||||
return {
|
||||
...value,
|
||||
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
|
||||
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
|
||||
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
|
||||
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
|
||||
return [entry as PromptHistoryStoredEntry]
|
||||
}),
|
||||
}
|
||||
}
|
||||
type PromptHistoryState = typeof PromptHistoryState.Type
|
||||
|
||||
function createComposerHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
@@ -51,12 +37,14 @@ function createComposerHistoryStore(
|
||||
|
||||
export function createComposerHistory() {
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
Persist.prompt(Persist.global("prompt-history")),
|
||||
PromptHistoryState,
|
||||
{ entries: [] },
|
||||
)
|
||||
const [shell, setShell, shellInit] = persisted(
|
||||
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
Persist.prompt(Persist.global("prompt-history-shell")),
|
||||
PromptHistoryState,
|
||||
{ entries: [] },
|
||||
)
|
||||
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
|
||||
return {
|
||||
|
||||
@@ -9,3 +9,15 @@ export function clonePrompt(prompt: Prompt): Prompt {
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
|
||||
const start = promptLength(prompt)
|
||||
const offset = start + 2
|
||||
return [
|
||||
...clonePrompt(prompt),
|
||||
{ type: "text", content: "\n\n", start, end: offset },
|
||||
...clonePrompt(following).map((part) =>
|
||||
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import {
|
||||
CommentStore,
|
||||
ComposerStore,
|
||||
DEFAULT_PROMPT,
|
||||
PromptHistoryState,
|
||||
type TextPart,
|
||||
type ImageAttachmentPart,
|
||||
type FileAttachmentPart,
|
||||
type LineComment,
|
||||
} from "./schema"
|
||||
|
||||
const text: TextPart = { type: "text", content: "hello", start: 0, end: 5 }
|
||||
const image: Omit<ImageAttachmentPart, "blob"> = {
|
||||
type: "image",
|
||||
id: "image",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
}
|
||||
const comment = { id: "comment", path: "src/app.ts", selection: { start: 1, end: 2 }, comment: "note", time: 1 }
|
||||
|
||||
describe("composer persistence schemas", () => {
|
||||
test("defaults missing or invalid fields independently and normalizes the cursor", () => {
|
||||
const decode = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)
|
||||
expect(decode({})).toEqual({ prompt: DEFAULT_PROMPT, context: { items: [] } })
|
||||
const value = decode({
|
||||
prompt: [null, { type: "unknown" }],
|
||||
cursor: -1,
|
||||
mode: "unknown",
|
||||
model: { providerID: 42, modelID: "model" },
|
||||
retry: { id: "bad", agent: "build", providerID: "provider", modelID: "model" },
|
||||
context: { items: [{ type: "file", path: "src/app.ts", commentID: "note", key: "stale" }, null] },
|
||||
})
|
||||
expect(value.prompt).toEqual(DEFAULT_PROMPT)
|
||||
expect(value.cursor).toBe(0)
|
||||
expect(value.mode).toBeUndefined()
|
||||
expect(value.model).toBeUndefined()
|
||||
expect(value.retry).toBeUndefined()
|
||||
expect(value.context.items).toEqual([
|
||||
{ type: "file", path: "src/app.ts", commentID: "note", key: "file:src/app.ts:undefined:undefined:c=note" },
|
||||
])
|
||||
expect(decode({ prompt: false, cursor: Infinity, context: null })).toEqual({
|
||||
prompt: DEFAULT_PROMPT,
|
||||
context: { items: [] },
|
||||
})
|
||||
const first = decode({})
|
||||
first.prompt[0] = { type: "text", content: "changed", start: 0, end: 7 }
|
||||
expect(decode({}).prompt).toEqual(DEFAULT_PROMPT)
|
||||
})
|
||||
|
||||
test("drops invalid parts without losing valid mentions or optional field recovery", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
prompt: [
|
||||
text,
|
||||
{ type: "agent", content: "@build", start: 5, end: 11, name: "build" },
|
||||
{ type: "skill", content: "@effect", start: 11, end: 18, id: "effect", name: "Effect" },
|
||||
{ type: "agent", content: "@broken", start: 18, end: 25, name: 42 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/app.ts",
|
||||
content: "@src/app.ts",
|
||||
start: 18,
|
||||
end: 29,
|
||||
selection: { startLine: "broken" },
|
||||
mime: 42,
|
||||
filename: "app.ts",
|
||||
source: { type: "invalid" },
|
||||
},
|
||||
],
|
||||
model: { providerID: "provider", modelID: "model", variant: null },
|
||||
retry: { id: "msg_retry", agent: "build", providerID: "provider", modelID: "model", variant: false },
|
||||
})
|
||||
expect(value.prompt.map((part) => part.type)).toEqual(["text", "agent", "skill", "file"])
|
||||
expect(value.prompt[3]).toEqual({
|
||||
type: "file",
|
||||
path: "src/app.ts",
|
||||
content: "@src/app.ts",
|
||||
start: 18,
|
||||
end: 29,
|
||||
filename: "app.ts",
|
||||
})
|
||||
expect(value.model?.variant).toBeNull()
|
||||
expect(value.retry).toEqual({
|
||||
id: SessionMessage.ID.make("msg_retry"),
|
||||
agent: "build",
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
})
|
||||
expect(
|
||||
Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)(Schema.encodeSync(ComposerStore)(value)),
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("preserves file source variants through canonical round trips", () => {
|
||||
const sourceText = { value: "@source", start: 0, end: 7 }
|
||||
const sources: NonNullable<FileAttachmentPart["source"]>[] = [
|
||||
{ type: "file", path: "src/app.ts", text: sourceText },
|
||||
{ type: "resource", clientName: "docs", uri: "docs://example", text: sourceText },
|
||||
{
|
||||
type: "symbol",
|
||||
path: "src/app.ts",
|
||||
name: "App",
|
||||
kind: 1,
|
||||
range: { start: { line: 1, character: 0 }, end: { line: 2, character: 1 } },
|
||||
text: sourceText,
|
||||
},
|
||||
]
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
prompt: sources.map((source) => ({
|
||||
type: "file",
|
||||
path: "src/app.ts",
|
||||
content: "@source",
|
||||
start: 0,
|
||||
end: 7,
|
||||
source,
|
||||
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 1 },
|
||||
})),
|
||||
})
|
||||
expect(value.prompt).toHaveLength(3)
|
||||
expect(value.prompt.map((part) => part.type === "file" && part.source)).toEqual(sources)
|
||||
expect(
|
||||
Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)(Schema.encodeSync(ComposerStore)(value)),
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
prompt: [
|
||||
{ ...image, dataUrl: "data:image/png;base64,YQ==", sourcePath: "/image.png" },
|
||||
{ ...image, blob: { id: "data:image/png;base64,Yg==" } },
|
||||
{ ...image, blob: { id: "hash", url: "blob:hydrated" } },
|
||||
{ ...image, blob: { id: "missing" } },
|
||||
{ ...image, blob: { id: "bad", url: "https://example.com/image.png" } },
|
||||
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
|
||||
],
|
||||
})
|
||||
expect(value.prompt).toHaveLength(3)
|
||||
expect(value.prompt[0]).toEqual({
|
||||
...image,
|
||||
sourcePath: "/image.png",
|
||||
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
|
||||
})
|
||||
const encoded = Schema.encodeSync(ComposerStore)(value)
|
||||
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
|
||||
expect(
|
||||
Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)(encoded),
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("migrates legacy history arrays and recovers entries, parts, and comments independently", () => {
|
||||
const value = Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
|
||||
entries: [
|
||||
[text, null, { ...image, dataUrl: "data:image/png;base64,YQ==" }],
|
||||
null,
|
||||
{},
|
||||
{ prompt: false, comments: [] },
|
||||
{ prompt: [text, { type: "invalid" }], comments: [comment, { ...comment, selection: null }, false] },
|
||||
{ prompt: [text], comments: false },
|
||||
],
|
||||
})
|
||||
expect(value.entries).toHaveLength(3)
|
||||
expect(value.entries[0].prompt).toHaveLength(2)
|
||||
expect(value.entries[0].comments).toEqual([])
|
||||
expect(value.entries[1]).toEqual({ prompt: [text], comments: [comment] })
|
||||
expect(value.entries[2]).toEqual({ prompt: [text], comments: [] })
|
||||
const encoded = Schema.encodeSync(PromptHistoryState)(value)
|
||||
expect(encoded.entries?.every((entry) => !Array.isArray(entry))).toBe(true)
|
||||
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
|
||||
expect(Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))(encoded)).toEqual(
|
||||
value,
|
||||
)
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({ entries: "invalid" }),
|
||||
).toEqual({ entries: [] })
|
||||
})
|
||||
|
||||
test("recovers comments per file and entry without discarding healthy siblings", () => {
|
||||
const line: LineComment = {
|
||||
id: "comment",
|
||||
file: "src/app.ts",
|
||||
selection: { start: 1, end: 2, side: "additions", endSide: "deletions" },
|
||||
comment: "note",
|
||||
time: 1,
|
||||
}
|
||||
const value = Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({
|
||||
comments: {
|
||||
"src/app.ts": [line, null, { ...line, time: "bad" }, { ...line, selection: { start: 2, end: 4, side: "bad" } }],
|
||||
"broken.ts": { invalid: true },
|
||||
"healthy.ts": [{ ...line, file: "healthy.ts" }],
|
||||
},
|
||||
})
|
||||
expect(value.comments["src/app.ts"]).toEqual([line, { ...line, selection: { start: 2, end: 4 } }])
|
||||
expect(value.comments["broken.ts"]).toEqual([])
|
||||
expect(value.comments["healthy.ts"]).toEqual([{ ...line, file: "healthy.ts" }])
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))(
|
||||
Schema.encodeSync(CommentStore)(value),
|
||||
),
|
||||
).toEqual(value)
|
||||
expect(Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({})).toEqual({
|
||||
comments: {},
|
||||
})
|
||||
expect(Schema.decodeUnknownSync(Persistence.withInitial(CommentStore, { comments: {} }))({ comments: [] })).toEqual(
|
||||
{ comments: {} },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { FileSelection, SelectedLineRange } from "@/workspaces/files/types"
|
||||
|
||||
const PartBase = {
|
||||
content: Schema.String,
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
}
|
||||
|
||||
const SourceText = Schema.Struct({ value: Schema.String, start: Schema.Number, end: Schema.Number })
|
||||
const Position = Schema.Struct({ line: Schema.Number, character: Schema.Number })
|
||||
const FilePartSource = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("file"), text: SourceText, path: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("symbol"),
|
||||
text: SourceText,
|
||||
path: Schema.String,
|
||||
range: Schema.Struct({ start: Position, end: Position }),
|
||||
name: Schema.String,
|
||||
kind: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("resource"), text: SourceText, clientName: Schema.String, uri: Schema.String }),
|
||||
])
|
||||
|
||||
export const TextPart = Persistence.struct({ type: Schema.Literal("text"), ...PartBase })
|
||||
export type TextPart = typeof TextPart.Type
|
||||
|
||||
export const FileAttachmentPart = Persistence.struct({
|
||||
type: Schema.Literal("file"),
|
||||
...PartBase,
|
||||
path: Schema.String,
|
||||
selection: Persistence.optional(FileSelection),
|
||||
mime: Persistence.optional(Schema.String),
|
||||
filename: Persistence.optional(Schema.String),
|
||||
url: Persistence.optional(Schema.String),
|
||||
source: Persistence.optional(FilePartSource),
|
||||
})
|
||||
export type FileAttachmentPart = typeof FileAttachmentPart.Type
|
||||
|
||||
export const AgentPart = Persistence.struct({ type: Schema.Literal("agent"), ...PartBase, name: Schema.String })
|
||||
export type AgentPart = typeof AgentPart.Type
|
||||
|
||||
export const SkillPart = Persistence.struct({
|
||||
type: Schema.Literal("skill"),
|
||||
...PartBase,
|
||||
id: Skill.ID,
|
||||
name: Skill.Name,
|
||||
})
|
||||
export type SkillPart = typeof SkillPart.Type
|
||||
|
||||
const ImageFields = {
|
||||
type: Schema.Literal("image"),
|
||||
id: Schema.String,
|
||||
filename: Schema.String,
|
||||
sourcePath: Persistence.optional(Schema.String),
|
||||
mime: Schema.String,
|
||||
}
|
||||
const Image = Persistence.struct({
|
||||
...ImageFields,
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
|
||||
})
|
||||
|
||||
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
|
||||
// inline data remains usable, but unresolved references are not renderable.
|
||||
export const ImageAttachmentPart = Schema.Struct({
|
||||
...ImageFields,
|
||||
blob: Persistence.optional(
|
||||
Schema.Struct({ id: Persistence.optional(Schema.String), url: Persistence.optional(Schema.String) }),
|
||||
),
|
||||
dataUrl: Persistence.optional(Schema.String),
|
||||
}).pipe(
|
||||
Schema.decodeTo(Schema.toType(Image), {
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const id = value.blob?.id ?? value.dataUrl ?? ""
|
||||
const url = value.blob?.url
|
||||
return {
|
||||
type: value.type,
|
||||
id: value.id,
|
||||
filename: value.filename,
|
||||
sourcePath: value.sourcePath,
|
||||
mime: value.mime,
|
||||
blob: {
|
||||
id,
|
||||
url: url?.startsWith("blob:") || url?.startsWith("data:") ? url : id.startsWith("data:") ? id : "",
|
||||
},
|
||||
}
|
||||
}),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
)
|
||||
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
|
||||
|
||||
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
|
||||
export type ContentPart = typeof ContentPart.Type
|
||||
export const Prompt = Persistence.array(ContentPart)
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export const PromptModel = Persistence.struct({
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
variant: Persistence.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
export type PromptModel = typeof PromptModel.Type
|
||||
|
||||
export const FileContextItem = Persistence.struct({
|
||||
type: Schema.Literal("file"),
|
||||
path: Schema.String,
|
||||
selection: Persistence.optional(FileSelection),
|
||||
comment: Persistence.optional(Schema.String),
|
||||
commentID: Persistence.optional(Schema.String),
|
||||
commentOrigin: Persistence.optional(Schema.Literals(["review", "file"])),
|
||||
preview: Persistence.optional(Schema.String),
|
||||
})
|
||||
export type FileContextItem = typeof FileContextItem.Type
|
||||
export type ContextItem = FileContextItem
|
||||
|
||||
export function contextItemKey(item: ContextItem) {
|
||||
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
const ContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
|
||||
Schema.decodeTo(Persistence.struct({ ...FileContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
|
||||
decode: SchemaGetter.transform((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
encode: SchemaGetter.transform((item) => item),
|
||||
}),
|
||||
)
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export const ComposerStore = Persistence.struct({
|
||||
prompt: Prompt.pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((prompt) =>
|
||||
prompt.length ? prompt : DEFAULT_PROMPT.map((part) => ({ ...part })),
|
||||
),
|
||||
encode: SchemaGetter.transform((prompt) => prompt),
|
||||
}),
|
||||
),
|
||||
cursor: Persistence.optional(
|
||||
Schema.Finite.pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((cursor) => Math.max(0, cursor)),
|
||||
encode: SchemaGetter.transform((cursor) => cursor),
|
||||
}),
|
||||
),
|
||||
),
|
||||
model: Persistence.optional(PromptModel),
|
||||
mode: Persistence.optional(Schema.Literals(["normal", "shell"])),
|
||||
retry: Persistence.optional(
|
||||
Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
agent: Schema.String,
|
||||
providerID: Schema.String,
|
||||
modelID: Schema.String,
|
||||
variant: Persistence.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
context: Persistence.struct({ items: Persistence.array(ContextEntry) }),
|
||||
})
|
||||
export type ComposerStore = typeof ComposerStore.Type
|
||||
|
||||
export const LineComment = Persistence.struct({
|
||||
id: Schema.String,
|
||||
file: Schema.String,
|
||||
selection: SelectedLineRange,
|
||||
comment: Schema.String,
|
||||
time: Schema.Number,
|
||||
})
|
||||
export type LineComment = typeof LineComment.Type
|
||||
|
||||
export const CommentStore = Persistence.struct({
|
||||
comments: Schema.Record(Schema.String, Schema.mutableKey(Persistence.array(LineComment))),
|
||||
})
|
||||
export type CommentStore = typeof CommentStore.Type
|
||||
|
||||
export const PromptHistoryComment = Persistence.struct({
|
||||
id: Schema.String,
|
||||
path: Schema.String,
|
||||
selection: SelectedLineRange,
|
||||
comment: Schema.String,
|
||||
time: Schema.Number,
|
||||
origin: Persistence.optional(Schema.Literals(["review", "file"])),
|
||||
preview: Persistence.optional(Schema.String),
|
||||
})
|
||||
export type PromptHistoryComment = typeof PromptHistoryComment.Type
|
||||
|
||||
// History entries require a prompt array; only its individual parts recover.
|
||||
const HistoryPrompt = Schema.Array(Persistence.fallback(Schema.UndefinedOr(ContentPart), () => undefined)).pipe(
|
||||
Schema.decodeTo(Schema.toType(Prompt), {
|
||||
decode: SchemaGetter.transform((parts) => parts.filter((part) => part !== undefined)),
|
||||
encode: SchemaGetter.transform((parts) => parts),
|
||||
}),
|
||||
)
|
||||
const HistoryEntry = Schema.Struct({ prompt: HistoryPrompt, comments: Persistence.array(PromptHistoryComment) })
|
||||
export const PromptHistoryEntry = Schema.Union([HistoryEntry, HistoryPrompt]).pipe(
|
||||
Schema.decodeTo(Schema.toType(HistoryEntry), {
|
||||
decode: SchemaGetter.transform((entry) => ("prompt" in entry ? entry : { prompt: entry, comments: [] })),
|
||||
encode: SchemaGetter.transform((entry) => entry),
|
||||
}),
|
||||
)
|
||||
export type PromptHistoryEntry = typeof PromptHistoryEntry.Type
|
||||
|
||||
export const PromptHistoryState = Persistence.struct({ entries: Persistence.array(PromptHistoryEntry) })
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { createMemoryComposerState, DEFAULT_PROMPT, parseComposerStore } from "./state"
|
||||
import { Schema, Option } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createMemoryComposerState, DEFAULT_PROMPT } from "./state"
|
||||
import { ComposerStore } from "./schema"
|
||||
|
||||
describe("prompt state initialization", () => {
|
||||
test("initializes prompt text, cursor, and model together", () => {
|
||||
@@ -29,7 +32,9 @@ describe("prompt state initialization", () => {
|
||||
})
|
||||
|
||||
test("parses persisted state into one trusted current shape", () => {
|
||||
const parsed = parseComposerStore({
|
||||
const parsed = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
prompt: [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{ type: "skill", id: "effect", name: "Effect", content: "@effect", start: 5, end: 12 },
|
||||
@@ -105,6 +110,6 @@ describe("prompt state initialization", () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(parseComposerStore("not an object")).toBeUndefined()
|
||||
expect(Option.isNone(Schema.decodeUnknownOption(ComposerStore)("not an object"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,127 +1,41 @@
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import { batch, untrack, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { BlobReference } from "@/runtime/persistence/drafts"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { clonePrompt } from "./prompt-parts"
|
||||
import {
|
||||
ComposerStore,
|
||||
DEFAULT_PROMPT,
|
||||
contextItemKey,
|
||||
type ContextItem,
|
||||
type FileContextItem,
|
||||
type Prompt,
|
||||
type PromptModel,
|
||||
} from "./schema"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
export { DEFAULT_PROMPT } from "./schema"
|
||||
export type {
|
||||
AgentPart,
|
||||
ComposerStore,
|
||||
ContentPart,
|
||||
ContextItem,
|
||||
FileAttachmentPart,
|
||||
FileContextItem,
|
||||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
SkillPart,
|
||||
TextPart,
|
||||
} from "./schema"
|
||||
|
||||
type FilePartSourceText = { value: string; start: number; end: number }
|
||||
type FilePartSource =
|
||||
| { text: FilePartSourceText; type: "file"; path: string }
|
||||
| {
|
||||
text: FilePartSourceText
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
name: string
|
||||
kind: number
|
||||
}
|
||||
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface AgentPart extends PartBase {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SkillPart extends PartBase {
|
||||
type: "skill"
|
||||
id: Skill.ID
|
||||
name: Skill.Name
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
blob: BlobReference
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | SkillPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export type PromptModel = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export type FileContextItem = {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type ContextItem = FileContextItem
|
||||
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export type ComposerStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
mode?: "normal" | "shell"
|
||||
retry?: {
|
||||
id: SessionMessage.ID
|
||||
agent: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
context: {
|
||||
items: (ContextItem & { key: string })[]
|
||||
}
|
||||
}
|
||||
|
||||
type InitialPrompt = {
|
||||
prompt?: string
|
||||
model?: PromptModel
|
||||
}
|
||||
|
||||
function contextItemKey(item: ContextItem) {
|
||||
if (item.type !== "file") return item.type
|
||||
const start = item.selection?.startLine
|
||||
const end = item.selection?.endLine
|
||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||
|
||||
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return key
|
||||
const digest = checksum(comment) ?? comment
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
@@ -129,34 +43,29 @@ export function isCommentItem(item: ContextItem | (ContextItem & { key: string }
|
||||
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
|
||||
return {
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
setStore("retry", undefined)
|
||||
})
|
||||
batch(() =>
|
||||
setStore({
|
||||
prompt: clonePrompt(prompt),
|
||||
...(cursorPosition !== undefined ? { cursor: cursorPosition } : {}),
|
||||
retry: undefined,
|
||||
}),
|
||||
)
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
setStore("retry", undefined)
|
||||
})
|
||||
batch(() => setStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: 0, retry: undefined }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function composerTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
const target =
|
||||
"draftID" in scope
|
||||
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
: Persist.prompt({
|
||||
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
|
||||
...(serverScope === ServerScope.local
|
||||
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
|
||||
: {}),
|
||||
})
|
||||
return { ...target, migrate: parseComposerStore }
|
||||
return "draftID" in scope
|
||||
? Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
: Persist.prompt({
|
||||
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
|
||||
...(serverScope === ServerScope.local
|
||||
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
|
||||
@@ -172,206 +81,11 @@ function initialComposerStore(initial?: InitialPrompt): ComposerStore {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseComposerStore(value: unknown): ComposerStore | undefined {
|
||||
if (!record(value)) return
|
||||
const prompt = Array.isArray(value.prompt) ? value.prompt.flatMap(parsePart) : []
|
||||
const context = record(value.context) && Array.isArray(value.context.items) ? value.context.items : []
|
||||
const model = parseModel(value.model)
|
||||
const retry = parseRetry(value.retry)
|
||||
return {
|
||||
prompt: prompt.length ? prompt : clonePrompt(DEFAULT_PROMPT),
|
||||
...(typeof value.cursor === "number" && Number.isFinite(value.cursor) ? { cursor: Math.max(0, value.cursor) } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(value.mode === "normal" || value.mode === "shell" ? { mode: value.mode } : {}),
|
||||
...(retry ? { retry } : {}),
|
||||
context: {
|
||||
items: context.flatMap((item) => {
|
||||
const parsed = parseContextItem(item)
|
||||
return parsed ? [{ ...parsed, key: contextItemKey(parsed) }] : []
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetry(value: unknown): ComposerStore["retry"] {
|
||||
if (
|
||||
!record(value) ||
|
||||
typeof value.id !== "string" ||
|
||||
!value.id.startsWith("msg_") ||
|
||||
typeof value.agent !== "string" ||
|
||||
typeof value.providerID !== "string" ||
|
||||
typeof value.modelID !== "string"
|
||||
) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
id: SessionMessage.ID.make(value.id),
|
||||
agent: value.agent,
|
||||
providerID: value.providerID,
|
||||
modelID: value.modelID,
|
||||
...(typeof value.variant === "string" ? { variant: value.variant } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePart(value: unknown): ContentPart[] {
|
||||
if (!record(value) || typeof value.type !== "string") return []
|
||||
if (value.type === "image") {
|
||||
const legacy = typeof value.dataUrl === "string" ? value.dataUrl : undefined
|
||||
const blobID = record(value.blob) && typeof value.blob.id === "string" ? value.blob.id : legacy
|
||||
const hydrated = record(value.blob) && typeof value.blob.url === "string" ? value.blob.url : undefined
|
||||
const blobURL =
|
||||
hydrated?.startsWith("blob:") || hydrated?.startsWith("data:")
|
||||
? hydrated
|
||||
: blobID?.startsWith("data:")
|
||||
? blobID
|
||||
: undefined
|
||||
if (
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.filename !== "string" ||
|
||||
typeof value.mime !== "string" ||
|
||||
!blobID ||
|
||||
!blobURL
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: "image",
|
||||
id: value.id,
|
||||
filename: value.filename,
|
||||
mime: value.mime,
|
||||
blob: { id: blobID, url: blobURL },
|
||||
...(typeof value.sourcePath === "string" ? { sourcePath: value.sourcePath } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (typeof value.content !== "string" || typeof value.start !== "number" || typeof value.end !== "number") return []
|
||||
if (value.type === "text") return [{ type: "text", content: value.content, start: value.start, end: value.end }]
|
||||
if (value.type === "agent" && typeof value.name === "string") {
|
||||
return [{ type: "agent", name: value.name, content: value.content, start: value.start, end: value.end }]
|
||||
}
|
||||
if (value.type === "skill" && typeof value.id === "string" && typeof value.name === "string") {
|
||||
return [
|
||||
{
|
||||
type: "skill",
|
||||
id: Skill.ID.make(value.id),
|
||||
name: Skill.Name.make(value.name),
|
||||
content: value.content,
|
||||
start: value.start,
|
||||
end: value.end,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (value.type !== "file" || typeof value.path !== "string") return []
|
||||
const selection = parseSelection(value.selection)
|
||||
const source = parseSource(value.source)
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
path: value.path,
|
||||
content: value.content,
|
||||
start: value.start,
|
||||
end: value.end,
|
||||
...(typeof value.mime === "string" ? { mime: value.mime } : {}),
|
||||
...(typeof value.filename === "string" ? { filename: value.filename } : {}),
|
||||
...(typeof value.url === "string" ? { url: value.url } : {}),
|
||||
...(selection ? { selection } : {}),
|
||||
...(source ? { source } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function parseContextItem(value: unknown): ContextItem | undefined {
|
||||
if (!record(value) || value.type !== "file" || typeof value.path !== "string") return
|
||||
const selection = parseSelection(value.selection)
|
||||
const origin = value.commentOrigin === "review" || value.commentOrigin === "file" ? value.commentOrigin : undefined
|
||||
return {
|
||||
type: "file",
|
||||
path: value.path,
|
||||
...(selection ? { selection } : {}),
|
||||
...(typeof value.comment === "string" ? { comment: value.comment } : {}),
|
||||
...(typeof value.commentID === "string" ? { commentID: value.commentID } : {}),
|
||||
...(origin ? { commentOrigin: origin } : {}),
|
||||
...(typeof value.preview === "string" ? { preview: value.preview } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseModel(value: unknown): PromptModel | undefined {
|
||||
if (!record(value) || typeof value.providerID !== "string" || typeof value.modelID !== "string") return
|
||||
return {
|
||||
providerID: value.providerID,
|
||||
modelID: value.modelID,
|
||||
...(typeof value.variant === "string" || value.variant === null ? { variant: value.variant } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseSelection(value: unknown): FileSelection | undefined {
|
||||
if (!record(value)) return
|
||||
if (
|
||||
typeof value.startLine !== "number" ||
|
||||
typeof value.startChar !== "number" ||
|
||||
typeof value.endLine !== "number" ||
|
||||
typeof value.endChar !== "number"
|
||||
) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
startLine: value.startLine,
|
||||
startChar: value.startChar,
|
||||
endLine: value.endLine,
|
||||
endChar: value.endChar,
|
||||
}
|
||||
}
|
||||
|
||||
function parseSource(value: unknown): FilePartSource | undefined {
|
||||
if (!record(value) || !record(value.text)) return
|
||||
if (
|
||||
typeof value.text.value !== "string" ||
|
||||
typeof value.text.start !== "number" ||
|
||||
typeof value.text.end !== "number"
|
||||
) {
|
||||
return
|
||||
}
|
||||
const text = { value: value.text.value, start: value.text.start, end: value.text.end }
|
||||
if (value.type === "file" && typeof value.path === "string") return { type: "file", path: value.path, text }
|
||||
if (value.type === "resource" && typeof value.clientName === "string" && typeof value.uri === "string") {
|
||||
return { type: "resource", clientName: value.clientName, uri: value.uri, text }
|
||||
}
|
||||
if (
|
||||
value.type !== "symbol" ||
|
||||
typeof value.path !== "string" ||
|
||||
typeof value.name !== "string" ||
|
||||
typeof value.kind !== "number" ||
|
||||
!record(value.range) ||
|
||||
!record(value.range.start) ||
|
||||
!record(value.range.end) ||
|
||||
typeof value.range.start.line !== "number" ||
|
||||
typeof value.range.start.character !== "number" ||
|
||||
typeof value.range.end.line !== "number" ||
|
||||
typeof value.range.end.character !== "number"
|
||||
) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
type: "symbol",
|
||||
path: value.path,
|
||||
name: value.name,
|
||||
kind: value.kind,
|
||||
text,
|
||||
range: {
|
||||
start: { line: value.range.start.line, character: value.range.start.character },
|
||||
end: { line: value.range.end.line, character: value.range.end.character },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function createComposerStateValue(store: ComposerStore, setStore: SetStoreFunction<ComposerStore>) {
|
||||
const actions = createComposerActions(setStore)
|
||||
const clearRetry = () => setStore("retry", undefined)
|
||||
const clearRetry = () => {
|
||||
if (untrack(() => store.retry) !== undefined) setStore("retry", undefined)
|
||||
}
|
||||
const value = {
|
||||
store: [() => store, setStore] as [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
|
||||
current: () => store.prompt,
|
||||
@@ -386,8 +100,8 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
|
||||
mode: {
|
||||
current: () => store.mode ?? "normal",
|
||||
set: (mode: "normal" | "shell") => {
|
||||
setStore("mode", mode)
|
||||
clearRetry()
|
||||
if (untrack(() => store.mode === mode && store.retry === undefined)) return
|
||||
setStore({ mode, retry: undefined })
|
||||
},
|
||||
},
|
||||
retry: {
|
||||
@@ -442,11 +156,7 @@ function createPersistedComposer(
|
||||
initial?: InitialPrompt,
|
||||
platform?: Platform,
|
||||
) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
target,
|
||||
createStore<ComposerStore>(initialComposerStore(initial)),
|
||||
platform,
|
||||
)
|
||||
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
|
||||
return { ready, ...createComposerStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
@@ -460,13 +170,7 @@ export function createComposerState(
|
||||
}
|
||||
|
||||
export function createDraftComposerState(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedComposer(
|
||||
{
|
||||
...Persist.prompt(Persist.draft(draftID, "prompt")),
|
||||
migrate: parseComposerStore,
|
||||
},
|
||||
initial,
|
||||
)
|
||||
return createPersistedComposer(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
|
||||
}
|
||||
|
||||
export type ComposerState = ReturnType<typeof createComposerState>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComposerState, ContextItem, Prompt } from "./state"
|
||||
import { appendPrompt, clonePrompt } from "./prompt-parts"
|
||||
|
||||
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
|
||||
|
||||
@@ -10,24 +11,36 @@ export function createComposerSubmission(input: {
|
||||
const initial = input.target
|
||||
let target = input.target
|
||||
let cleared: Prompt | undefined
|
||||
let following: Prompt | undefined
|
||||
let preserveDraft = false
|
||||
|
||||
return {
|
||||
prompt: input.prompt,
|
||||
context: input.context,
|
||||
target: () => target,
|
||||
clear() {
|
||||
if (initial !== target) initial.reset()
|
||||
target.reset()
|
||||
if (initial !== target) {
|
||||
initial.reset()
|
||||
// A preparing session may already have an unsent follow-up in its promoted composer.
|
||||
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
|
||||
following = clonePrompt(target.current())
|
||||
}
|
||||
if (!following) target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: ComposerStateTarget) {
|
||||
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
|
||||
input.context.forEach((item) => next.context.add(item))
|
||||
target = next
|
||||
preserveDraft = options?.preserveDraft ?? false
|
||||
},
|
||||
current: (value: ComposerStateTarget) => target === value,
|
||||
restore() {
|
||||
if (cleared !== undefined && target.current() !== cleared) return
|
||||
return { target, prompt: input.prompt, context: input.context }
|
||||
return {
|
||||
target,
|
||||
prompt: following ? appendPrompt(input.prompt, following) : input.prompt,
|
||||
context: input.context,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ describe("Composer submission", () => {
|
||||
|
||||
test("previews the first prompt while starting and hands it off before completing preparation", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "prepare my worktree" }).capture()
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const preview = Promise.withResolvers<SessionMessageUser>()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const calls: string[] = []
|
||||
@@ -247,9 +248,10 @@ describe("Composer submission", () => {
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start(_selection, _submission, message) {
|
||||
async start(_selection, submission, message) {
|
||||
preview.resolve(message)
|
||||
await ready.promise
|
||||
submission.retarget(promoted, { preserveDraft: true })
|
||||
return {
|
||||
session: target,
|
||||
cleanupReady: Promise.resolve(),
|
||||
@@ -257,6 +259,8 @@ describe("Composer submission", () => {
|
||||
expect(handoff).toHaveLength(1)
|
||||
expect(handoff[0]?.id).toBe(message.id)
|
||||
expect(handoff[0]?.text).toBe("prepare my worktree")
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
promoted.set([{ type: "text", content: "follow up", start: 0, end: 9 }], 9)
|
||||
calls.push("complete")
|
||||
},
|
||||
}
|
||||
@@ -271,6 +275,7 @@ describe("Composer submission", () => {
|
||||
await submitted
|
||||
expect(calls).toContain("complete")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "follow up", start: 0, end: 9 }])
|
||||
})
|
||||
|
||||
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
|
||||
|
||||
@@ -248,7 +248,8 @@ function restoreSubmission(
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
if (value.mode === "normal") {
|
||||
// A recovered follow-up changes the payload, so it must use a new admission ID.
|
||||
if (value.mode === "normal" && restored.prompt === submission.prompt) {
|
||||
restored.target.retry.set({
|
||||
id: value.id,
|
||||
agent: value.selection.agent,
|
||||
|
||||
@@ -73,6 +73,7 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const standalone = isStandalone()
|
||||
root.dataset.standalone = String(standalone)
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
|
||||
@@ -10,10 +10,15 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { HomeController } from "../model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
|
||||
export const HomeServersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
})
|
||||
|
||||
export function createHomeProjectsController(home: HomeController) {
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
@@ -22,10 +27,7 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerActionsController()
|
||||
const global = useGlobal()
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers"),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(promise) => promise.then(() => _state),
|
||||
|
||||
@@ -7,7 +7,12 @@ import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
|
||||
import {
|
||||
HOME_SESSION_LIMIT,
|
||||
loadHomeSessionIndex,
|
||||
mergeHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "@/home/sessions/index"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
@@ -25,8 +30,7 @@ import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
|
||||
|
||||
export type { HomeSessionRecord } from "./records"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount.
|
||||
// Keep the immutable result opaque so Solid Query does not recursively unwrap every session on mount.
|
||||
const selectSessions = (sessions: SessionInfo[]) => () => sessions
|
||||
export type HomeSessionGroup = {
|
||||
id: "today" | "yesterday" | "older"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user