Compare commits

..
Author SHA1 Message Date
Kit Langton f7b4162804 fix(server): use renamed plugin activation wait 2026-09-01 11:10:21 -04:00
594 changed files with 17563 additions and 17773 deletions
+2 -1
View File
@@ -417,6 +417,7 @@ 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
@@ -479,7 +480,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.workspace }}/packages/cli/dist
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
- name: Build
run: bun run build
+1 -1
View File
@@ -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`.
- 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.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
+31 -23
View File
@@ -418,7 +418,7 @@
"electron-window-state": "^5.0.3",
},
"devDependencies": {
"@brendonovich/vite-plugin-opencode": "0.1.3",
"@brendonovich/vite-plugin-opencode": "0.1.1",
"@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.10",
"@opentui/solid": ">=0.5.10",
"@opentui/core": ">=0.5.9",
"@opentui/solid": ">=0.5.9",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -730,7 +730,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
},
"packages/simulation": {
@@ -1050,7 +1049,6 @@
"@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",
@@ -1080,9 +1078,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -1597,7 +1595,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.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=="],
"@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=="],
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
@@ -2223,27 +2221,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"@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-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"@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/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/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=="],
"@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=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -3215,6 +3213,8 @@
"@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=="],
@@ -5453,6 +5453,8 @@
"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,6 +5669,8 @@
"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=="],
@@ -5815,7 +5819,9 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"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=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5847,6 +5853,8 @@
"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=="],
@@ -6257,8 +6265,6 @@
"@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=="],
@@ -6603,6 +6609,8 @@
"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=="],
-5
View File
@@ -87,11 +87,6 @@ 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
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"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="
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
}
}
+4 -5
View File
@@ -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.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
@@ -178,7 +178,6 @@
"@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",
"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"
}
}
@@ -11,23 +11,6 @@ story("raises the docked composer only in dark mode", async ({ mount, page }) =>
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}`)
@@ -1,13 +0,0 @@
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)
})
+3 -5
View File
@@ -85,16 +85,14 @@ 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 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`.
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`.
```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 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.
**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.
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.
@@ -106,7 +104,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 cached 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 prefetched 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.
@@ -1,37 +0,0 @@
# 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.
@@ -1,48 +0,0 @@
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`) })
})
}
@@ -1,174 +0,0 @@
/// <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")!,
)
@@ -1,11 +0,0 @@
<!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>
@@ -1,20 +0,0 @@
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,
},
})
@@ -1,10 +0,0 @@
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 },
})
@@ -1,44 +0,0 @@
# 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.
@@ -1,125 +0,0 @@
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()
})
}
@@ -1,24 +0,0 @@
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,
},
})
@@ -1,93 +0,0 @@
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)
}
@@ -1,41 +0,0 @@
# 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.
@@ -1,143 +0,0 @@
/// <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")!)
@@ -1,11 +0,0 @@
<!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>
@@ -1,66 +0,0 @@
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`) })
}
})
}
@@ -1,18 +0,0 @@
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,
},
})
@@ -1,13 +0,0 @@
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 })
},
})
@@ -1,51 +0,0 @@
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 },
})
@@ -1,44 +0,0 @@
# 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.
@@ -1,20 +0,0 @@
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" },
})
@@ -1,74 +0,0 @@
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
}
@@ -1,13 +0,0 @@
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') }
}
@@ -1,352 +0,0 @@
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)
}
@@ -1,17 +0,0 @@
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" },
],
},
},
],
})
@@ -1,197 +0,0 @@
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`) })
})
}
@@ -1,315 +0,0 @@
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]
}
@@ -1,234 +0,0 @@
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("")
}
@@ -1,166 +0,0 @@
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()
},
)
}
@@ -1,15 +0,0 @@
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 },
}
@@ -1,43 +0,0 @@
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,27 +48,20 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
expect(result.samples.length).toBeGreaterThan(0)
})
benchmark("loads only the selected restored tab's transcript", async ({ page, report }) => {
const loaded = new Set<string>()
benchmark("prefetches every open session tab", async ({ page, report }) => {
const prefetched = new Set<string>()
await mockStressTimeline(page, {
onMessages: (input) => {
if (!input.before && input.phase === "start") loaded.add(input.sessionID)
if (!input.before && input.phase === "start") prefetched.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 attention
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
expect([...loaded]).toEqual([fixture.sourceID])
report({ loaded: [...loaded] })
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
report({ prefetched: [...prefetched] })
})
@@ -49,7 +49,7 @@ scenarios.forEach((scenario) => {
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(requests).toHaveLength(requestsBefore + (scenario.cache === "cold" ? 1 : 0))
expect(requests).toHaveLength(requestsBefore)
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: scenario.cache === "cold" ? "on-demand" : "cached",
data: "prefetched",
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
requireReadyAnswer: true,
@@ -97,11 +97,16 @@ 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).toEqual([fixture.sourceID])
expect(requests.toSorted()).toEqual([fixture.sourceID, fixture.targetID].toSorted())
return requests
}
@@ -39,7 +39,13 @@ 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,
),
],
},
},
@@ -64,8 +70,6 @@ export async function setupTimelineBenchmark(
eventBatch: number
vcsDiff?: unknown[]
turnDiffs?: unknown[]
busy?: boolean
historyShape?: "mixed" | "tool-heavy"
},
) {
const events: EventPayload[] = []
@@ -73,47 +77,19 @@ 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,
sessionStatus: options.busy ? { [sessionID]: { type: "busy" } } : undefined,
pageMessages: () => ({ items: messages }),
pageMessages: () => ({
items: [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
currentUserMessage,
assistantMessage,
],
}),
events: () => events.splice(0, eventBatch),
eventRetry: 16,
})
@@ -137,11 +113,6 @@ 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: {
@@ -1,169 +0,0 @@
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())))
}
})
}
@@ -1,32 +0,0 @@
# 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.
@@ -1,32 +0,0 @@
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,8 +8,7 @@ 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("keeps a narrow session composer contained when invoking a built-in", async ({ page }) => {
await page.setViewportSize({ width: 800, height: 600 })
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -41,8 +40,6 @@ test("keeps a narrow session composer contained when invoking a built-in", async
.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()
@@ -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(
@@ -1,75 +0,0 @@
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,6 +107,8 @@ 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"]')).toHaveCount(0)
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
const help = drawer.getByRole("button", { name: "Help", exact: true })
await expect(settings).toBeVisible()
@@ -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 worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
@@ -9,17 +9,12 @@ 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 },
permissions: ["clipboard-read", "clipboard-write"],
})
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
@@ -29,44 +24,22 @@ 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.setAttribute("dir", "rtl")
element.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
@@ -80,14 +53,11 @@ 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()
@@ -118,133 +88,12 @@ 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)
@@ -255,7 +104,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 worktree", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace", 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)
@@ -295,7 +144,6 @@ 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()
@@ -316,16 +164,15 @@ 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}\n\n${followUp}`)
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 worktree", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace", 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)
@@ -335,17 +182,6 @@ 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[] = []
@@ -418,24 +254,6 @@ 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(
@@ -458,8 +276,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 worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
}
@@ -474,11 +292,7 @@ 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)
@@ -490,5 +304,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, title }
return { url, sessionID, messageID, message, shimmer }
}
@@ -0,0 +1,103 @@
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", target: 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,9 +6,8 @@ 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 activity in the tab session family", async ({ page }, info) => {
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
await mockServers(page)
await page.addInitScript(
({ serverA, serverB, sessionA, sessionB }) => {
@@ -26,13 +25,13 @@ test("tab busy indicator reflects activity in the tab session family", async ({
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(hrefB)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.goto(hrefA)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
// The parent is idle, but its tab remains active while the background child runs.
// 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.
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)
@@ -61,12 +60,8 @@ 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 ? { [childB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session")
return json(route, {
data: url.origin === serverB ? [currentSession(current), currentSession(childB)] : [currentSession(current)],
cursor: {},
})
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [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))
@@ -93,18 +93,11 @@ 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()
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(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
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" })
@@ -1,69 +0,0 @@
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()
})
}
@@ -0,0 +1,193 @@
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 } },
])
})
}
@@ -1,227 +0,0 @@
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*1 Read, 1 Grep$/)
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Read, 1 Grep")
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(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*1 Read, 1 Grep$/, /U2: Also check the retry path\./])
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2 Read, 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*1 Read, 1 Grep$/,
/^Used\s*2 Read, 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 1 Read, 1 Glob, 1 Grep, 1 List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, 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 1 Read, 1 Glob, 1 Grep, 1 List")
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, 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 1 Read, 1 Glob, 1 Grep, 1 List"])
expect(labels).toEqual(["Used 4 Read, Glob, Grep, 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" ? "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)
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}`)
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 2 Shell, 1 Agent, 1 Patch", exact: true })
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, 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("2 Shell, 1 Agent, 1 Patch")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("4 Shell, Agent, 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 1 Patch, 1 Read", exact: true })).toBeVisible()
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Patch, 1 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(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 1 Shell, 1 Patch", exact: true }).click()
await group.getByRole("button", { name: "Used 2 Shell, 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 1 Shell, 2 Patch", exact: true })).toHaveAttribute(
await expect(group.getByRole("button", { name: "Used 3 Shell, 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 1 Glob, 1 Grep", exact: true })
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 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")
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"]')
@@ -1,278 +0,0 @@
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: "Worktrees", exact: true }).click()
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Workspaces", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await expect(settings.getByText("No worktrees", { exact: true })).toHaveCount(0)
await expect(settings.getByText("No workspaces", { exact: true })).toHaveCount(0)
inventory.resolve()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await expect(settings.getByText("12 worktrees", { exact: true })).toBeVisible()
await expect(settings.getByText("12 workspaces", { exact: true })).toBeVisible()
sessions.resolve()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
@@ -63,25 +63,46 @@ 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: "Worktrees", exact: true }).click()
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("extensions opens without waiting for MCPs", async ({ page }) => {
test("extensions opens without waiting for MCPs or plugins", 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", target: "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()
@@ -89,7 +110,7 @@ test("extensions opens without waiting for MCPs", async ({ page }) => {
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("tab", { name: "Workspaces", 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,7 +22,8 @@ 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)
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible()
const titlebarRight = page.locator("#opencode-titlebar-right")
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("returns to the parent session with Escape", async ({ page }) => {
@@ -9,32 +9,6 @@ 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(
@@ -147,10 +121,7 @@ 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", showProjectName: true }, general: { showStatus: true } }),
)
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
@@ -173,26 +144,7 @@ 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"]')
@@ -219,7 +171,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
test("appearance experimental settings control vertical tab details", async ({ page }) => {
test("appearance experimental setting switches tab orientation", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
@@ -251,12 +203,6 @@ test("appearance experimental settings control vertical tab details", async ({ p
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 })
@@ -280,9 +226,6 @@ test("appearance experimental settings control vertical tab details", async ({ p
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"]')
@@ -134,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 worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
-3
View File
@@ -609,12 +609,9 @@ 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,6 +11,12 @@ beforeAll(async () => {
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./comments")
createCommentSessionForTest = mod.createCommentSessionForTest
})
+17 -5
View File
@@ -10,9 +10,14 @@ 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 } from "./schema"
export type LineComment = {
id: string
file: string
selection: SelectedLineRange
comment: string
time: number
}
type CommentFocus = { file: string; id: string }
@@ -32,6 +37,10 @@ function decodeSessionKey(key: string) {
}
}
type CommentStore = {
comments: Record<string, LineComment[]>
}
function aggregate(comments: Record<string, LineComment[]>) {
return Object.keys(comments)
.flatMap((file) => comments[file] ?? [])
@@ -170,9 +179,12 @@ 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"), CommentStore, {
comments: {},
})
const [store, setStore, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "comments"),
createStore<CommentStore>({
comments: {},
}),
)
const session = createCommentSessionState(store, setStore)
return {
+22 -26
View File
@@ -1,4 +1,4 @@
import { batch, untrack, type Accessor } from "solid-js"
import { batch, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import type {
ComposerAgentPart,
@@ -23,47 +23,43 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
return typeof value === "function" ? value() : value
}
const setStore = () => tuple()[1]
const clearRetry = () => {
if (untrack(() => store().retry) !== undefined) setStore()("retry", undefined)
}
const clearRetry = () => setStore()("retry", undefined)
return {
get state() {
return store()
},
setPrompt(prompt: ComposerPrompt, cursor?: number) {
// Persisted setters encode on every call, even inside a reactive batch.
batch(() => setStore()({ prompt, ...(cursor !== undefined ? { cursor } : {}), retry: undefined }))
batch(() => {
setStore()("prompt", prompt)
if (cursor !== undefined) setStore()("cursor", cursor)
clearRetry()
})
},
setCursor(cursor: number) {
if (untrack(() => store().cursor) === cursor) return
setStore()("cursor", cursor)
},
setMode(mode: "normal" | "shell") {
if (untrack(() => store().mode === mode && store().retry === undefined)) return
setStore()({ mode, retry: undefined })
setStore()("mode", mode)
clearRetry()
},
setText(content: string) {
batch(() =>
setStore()((state) => ({
prompt: [
{ type: "text", content, start: 0, end: content.length },
...state.prompt.filter((part) => part.type === "image"),
],
cursor: content.length,
retry: undefined,
})),
)
batch(() => {
setStore()("prompt", (prompt) => [
{ type: "text", content, start: 0, end: content.length },
...prompt.filter((part) => part.type === "image"),
])
setStore()("cursor", content.length)
clearRetry()
})
},
addText(content: string) {
const cursor = store().cursor ?? promptLength(store().prompt)
batch(() =>
setStore()((state) => ({
prompt: insertText(state.prompt, cursor, content),
cursor: cursor + content.length,
retry: undefined,
})),
)
batch(() => {
setStore()("prompt", (prompt) => insertText(prompt, cursor, content))
setStore()("cursor", cursor + content.length)
clearRetry()
})
},
removeContext(key: string) {
setStore()("context", "items", (items) => items.filter((item) => item.key !== key))
+4 -7
View File
@@ -91,7 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
event.currentTarget.value = ""
}}
/>
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
<Show when={state.popover.type !== "closed"}>
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
@@ -185,7 +185,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
if (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()
@@ -238,7 +238,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
style={buttons()}
>
<ComposerEditorAddMenu
disabled={view.draftOnly || state.mode === "shell"}
disabled={state.mode === "shell"}
title={i18n.t("ui.promptInput.add")}
keybind={props.attachKeybind ?? ["Mod", "U"]}
attachLabel={i18n.t("ui.promptInput.attachments")}
@@ -555,10 +555,7 @@ export function ComposerEditorAddMenu(props: {
aria-label={props.title}
/>
<Menu.Portal>
<Menu.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<Menu.Content style={{ "min-width": "180px" }}>
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
{props.attachLabel}
</Menu.Item>
@@ -29,7 +29,6 @@ export type ComposerSelectControl = {
}
export type ComposerEditorView = {
draftOnly?: boolean
placeholder?: Accessor<string>
add?: {
onAttach: () => void
@@ -333,7 +332,6 @@ 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())
@@ -349,7 +347,6 @@ 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) {
@@ -365,7 +362,6 @@ 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,9 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { Schema } from "effect"
import { PromptHistoryState } from "../schema"
import { Persistence } from "@/runtime/persistence/schema"
import { upgradeHistoryState } from "./store"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -60,11 +58,7 @@ describe("Composer history", () => {
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(
Schema.decodeUnknownSync(Persistence.withInitial(PromptHistoryState, { entries: [] }))({
entries: [text("stored")],
}),
).toEqual({
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
+15 -3
View File
@@ -1,12 +1,24 @@
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 {
+21 -9
View File
@@ -1,4 +1,4 @@
import { type SetStoreFunction, type Store } from "solid-js/store"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
@@ -8,14 +8,28 @@ 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 = typeof PromptHistoryState.Type
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]
}),
}
}
function createComposerHistoryStore(
normal: Store<PromptHistoryState>,
@@ -37,14 +51,12 @@ function createComposerHistoryStore(
export function createComposerHistory() {
const [normal, setNormal, normalInit] = persisted(
Persist.prompt(Persist.global("prompt-history")),
PromptHistoryState,
{ entries: [] },
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell, shellInit] = persisted(
Persist.prompt(Persist.global("prompt-history-shell")),
PromptHistoryState,
{ entries: [] },
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
)
const history = createComposerHistoryStore(normal, setNormal, shell, setShell)
return {
-12
View File
@@ -9,15 +9,3 @@ 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 },
),
]
}
-224
View File
@@ -1,224 +0,0 @@
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: {} },
)
})
})
-212
View File
@@ -1,212 +0,0 @@
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) })
+3 -8
View File
@@ -1,10 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { Skill } from "@opencode-ai/schema/skill"
import { Schema, Option } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { createMemoryComposerState, DEFAULT_PROMPT } from "./state"
import { ComposerStore } from "./schema"
import { createMemoryComposerState, DEFAULT_PROMPT, parseComposerStore } from "./state"
describe("prompt state initialization", () => {
test("initializes prompt text, cursor, and model together", () => {
@@ -32,9 +29,7 @@ describe("prompt state initialization", () => {
})
test("parses persisted state into one trusted current shape", () => {
const parsed = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
const parsed = parseComposerStore({
prompt: [
{ type: "text", content: "hello", start: 0, end: 5 },
{ type: "skill", id: "effect", name: "Effect", content: "@effect", start: 5, end: 12 },
@@ -110,6 +105,6 @@ describe("prompt state initialization", () => {
],
},
})
expect(Option.isNone(Schema.decodeUnknownOption(ComposerStore)("not an object"))).toBe(true)
expect(parseComposerStore("not an object")).toBeUndefined()
})
})
+343 -47
View File
@@ -1,41 +1,127 @@
import { batch, untrack, type Accessor } from "solid-js"
import { checksum } from "@opencode-ai/util/encode"
import { batch, 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"
export { DEFAULT_PROMPT } from "./schema"
export type {
AgentPart,
ComposerStore,
ContentPart,
ContextItem,
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
Prompt,
PromptModel,
SkillPart,
TextPart,
} from "./schema"
interface PartBase {
content: string
start: number
end: number
}
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()
}
@@ -43,29 +129,34 @@ export function isCommentItem(item: ContextItem | (ContextItem & { key: string }
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
return {
set(prompt: Prompt, cursorPosition?: number) {
batch(() =>
setStore({
prompt: clonePrompt(prompt),
...(cursorPosition !== undefined ? { cursor: cursorPosition } : {}),
retry: undefined,
}),
)
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
setStore("retry", undefined)
})
},
reset() {
batch(() => setStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: 0, retry: undefined }))
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
setStore("retry", undefined)
})
},
}
}
function composerTarget(serverScope: ServerScope, scope: PromptScope) {
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` }
: {}),
})
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 }
}
function initialComposerStore(initial?: InitialPrompt): ComposerStore {
@@ -81,11 +172,206 @@ 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 = () => {
if (untrack(() => store.retry) !== undefined) setStore("retry", undefined)
}
const clearRetry = () => setStore("retry", undefined)
const value = {
store: [() => store, setStore] as [Accessor<ComposerStore>, SetStoreFunction<ComposerStore>],
current: () => store.prompt,
@@ -100,8 +386,8 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
mode: {
current: () => store.mode ?? "normal",
set: (mode: "normal" | "shell") => {
if (untrack(() => store.mode === mode && store.retry === undefined)) return
setStore({ mode, retry: undefined })
setStore("mode", mode)
clearRetry()
},
},
retry: {
@@ -156,7 +442,11 @@ function createPersistedComposer(
initial?: InitialPrompt,
platform?: Platform,
) {
const [store, setStore, _, ready] = persisted(target, ComposerStore, initialComposerStore(initial), platform)
const [store, setStore, _, ready] = persisted(
target,
createStore<ComposerStore>(initialComposerStore(initial)),
platform,
)
return { ready, ...createComposerStateValue(store, setStore) }
}
@@ -170,7 +460,13 @@ export function createComposerState(
}
export function createDraftComposerState(draftID: string, initial?: InitialPrompt) {
return createPersistedComposer(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
return createPersistedComposer(
{
...Persist.prompt(Persist.draft(draftID, "prompt")),
migrate: parseComposerStore,
},
initial,
)
}
export type ComposerState = ReturnType<typeof createComposerState>
+4 -17
View File
@@ -1,5 +1,4 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt } from "./prompt-parts"
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
@@ -11,36 +10,24 @@ 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()
// 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()
if (initial !== target) initial.reset()
target.reset()
cleared = target.current()
},
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
retarget(next: ComposerStateTarget) {
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: following ? appendPrompt(input.prompt, following) : input.prompt,
context: input.context,
}
return { target, prompt: input.prompt, context: input.context }
},
}
}
+1 -6
View File
@@ -231,7 +231,6 @@ 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[] = []
@@ -248,10 +247,9 @@ 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(),
@@ -259,8 +257,6 @@ 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")
},
}
@@ -275,7 +271,6 @@ 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 () => {
+1 -2
View File
@@ -248,8 +248,7 @@ function restoreSubmission(
preview: item.preview,
})),
)
// A recovered follow-up changes the payload, so it must use a new admission ID.
if (value.mode === "normal" && restored.prompt === submission.prompt) {
if (value.mode === "normal") {
restored.target.retry.set({
id: value.id,
agent: value.selection.agent,
-1
View File
@@ -73,7 +73,6 @@ 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,15 +10,10 @@ 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 { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { createStore } from "solid-js/store"
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()
@@ -27,7 +22,10 @@ export function createHomeProjectsController(home: HomeController) {
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
const global = useGlobal()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(promise) => promise.then(() => _state),
@@ -7,12 +7,7 @@ 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 {
HOME_SESSION_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
retainHomeSessions,
} from "@/home/sessions/index"
import { 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"
@@ -30,7 +25,8 @@ import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
// Keep the immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const HOME_SESSION_LIMIT = 64
// Keep the large 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"
+7 -116
View File
@@ -1,15 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
import {
HOME_SESSION_INDEX_LIMIT,
HOME_SESSION_LIMIT,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
parseHomeSessionIndex,
retainHomeSessions,
} from "./index"
import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, parseHomeSessionIndex, retainHomeSessions } from "./index"
const session = (id: string, input: Partial<SessionInfo> = {}) =>
({
@@ -21,65 +12,19 @@ const session = (id: string, input: Partial<SessionInfo> = {}) =>
...input,
}) as SessionInfo
// The loader anchors its recent window on the wall clock, so fixtures do too.
const now = Date.now()
const minute = 60_000
// One session per minute going back from `now`; index 0 is the newest, which
// is the server's list order.
const history = (directory: string, count: number) =>
Array.from({ length: count }, (_, index) =>
session(`${directory}-${String(index).padStart(5, "0")}`, {
time: { created: now - (index + 1) * minute - 1000, updated: now - (index + 1) * minute },
location: { directory },
}),
)
const ids = (sessions: SessionInfo[]) => sessions.map((item) => item.id)
describe("Home session index", () => {
test("follows cursors across pages and bounds the result per directory", async () => {
const all = history("/repo", HOME_V2_SESSION_PAGE_LIMIT + 1)
test("loads all pages", async () => {
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
const calls: Array<{ cursor?: string; parentID: null }> = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
if (!input.cursor) return { data: all.slice(0, HOME_V2_SESSION_PAGE_LIMIT), cursor: { next: "next" } }
return { data: all.slice(HOME_V2_SESSION_PAGE_LIMIT), cursor: {} }
if (!input.cursor) return { data: first, cursor: { next: "next" } }
return { data: [session("last")], cursor: {} }
})
expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"])
expect(calls.every((call) => call.parentID === null)).toBe(true)
// Newest rows in server order, plus the recent window beyond the limit.
expect(ids(result)).toEqual(ids(all.slice(0, HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)))
})
test("folds pages so every directory keeps its newest sessions", async () => {
const busy = history("/busy", HOME_V2_SESSION_PAGE_LIMIT + 300)
const quiet = history("/quiet", 3).map((item) => ({
...item,
time: { created: item.time.created - 6000 * minute, updated: item.time.updated - 6000 * minute },
}))
// Server order is global by updated time: /quiet is older than all of /busy
// and only arrives on the second page.
const result = await loadHomeSessionIndex(async (input) => {
if (!input.cursor) return { data: busy.slice(0, HOME_V2_SESSION_PAGE_LIMIT), cursor: { next: "next" } }
return { data: [...busy.slice(HOME_V2_SESSION_PAGE_LIMIT), ...quiet], cursor: {} }
})
expect(ids(result.filter((item) => item.location.directory === "/quiet"))).toEqual(ids(quiet))
expect(ids(result.filter((item) => item.location.directory === "/busy"))).toEqual(
ids(busy.slice(0, HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)),
)
})
test("drops archived sessions before they can occupy a retained slot", async () => {
const archived = history("/repo", 200).map((item) => ({ ...item, time: { ...item.time, archived: now } }))
const live = history("/repo", 10).map((item) => ({
...item,
id: `live-${item.id}`,
time: { created: item.time.created - 300 * minute, updated: item.time.updated - 300 * minute },
}))
const result = await loadHomeSessionIndex(async () => ({ data: [...archived, ...live], cursor: {} }))
expect(ids(result)).toEqual(ids(live))
})
test("keeps only visible roots", () => {
@@ -93,6 +38,7 @@ describe("Home session index", () => {
})
test("preserves the per-directory retention limit", () => {
const now = Date.now()
const result = retainHomeSessions(
[session("a", { time: { created: 1, updated: 1 } }), session("b", { time: { created: 2, updated: 2 } })],
1,
@@ -101,58 +47,3 @@ describe("Home session index", () => {
expect(result.map((item) => item.id)).toEqual(["b"])
})
})
// Home merges locally known sessions and pending removals into the fetched
// index on every store change, then retains per directory for display and
// search. The loaded subset must resolve to the same set as the complete index.
describe("Home session index parity with the complete index", () => {
const complete = [...history("/a", 400), ...history("/b", 90), ...history("/c", 5)]
const view = (index: SessionInfo[], known: SessionInfo[], removed = new Set<string>()) =>
ids(
retainHomeSessions(
mergeHomeSessionIndex(index, known).filter((item) => !removed.has(item.id)),
HOME_SESSION_LIMIT,
now,
),
).toSorted()
const loaded = () => loadHomeSessionIndex(async () => ({ data: complete, cursor: {} }))
test("without local changes", async () => {
const result = view(await loaded(), [])
expect(result).toEqual(view(complete, []))
expect(result).toHaveLength(HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT + 90 + 5)
})
test("with new, re-timed, and unlisted-directory local sessions", async () => {
const known = [
session("a-fresh", { time: { created: now, updated: now }, location: { directory: "/a" } }),
// A fetched session that fell outside retention but was updated locally.
{ ...complete[300], time: { created: now - 1, updated: now } },
session("d-new", { time: { created: now, updated: now }, location: { directory: "/d" } }),
]
const result = view(await loaded(), known)
expect(result).toEqual(view(complete, known))
expect(result).toContain("a-fresh")
expect(result).toContain(complete[300].id)
expect(result).toContain("d-new")
})
test("with pending removals up to the recent-window bucket size", async () => {
const removed = new Set(ids(complete.slice(0, SESSION_RECENT_LIMIT)))
const result = view(await loaded(), [], removed)
expect(result).toEqual(view(complete, [], removed))
expect(result).toHaveLength(HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT + 90 + 5)
expect(result.some((id) => removed.has(id))).toBe(false)
})
test("with a directory entirely inside the recent window", async () => {
const hot = history("/hot", 300).map((item, index) => ({
...item,
time: { created: now - index * 1000 - 500, updated: now - index * 1000 },
}))
expect(hot.every((item) => item.time.updated > now - SESSION_RECENT_WINDOW)).toBe(true)
const index = await loadHomeSessionIndex(async () => ({ data: hot, cursor: {} }))
expect(index).toHaveLength(HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)
expect(view(index, [])).toEqual(view(hot, []))
})
})
+3 -14
View File
@@ -3,13 +3,6 @@ import { pathKey } from "@/workspaces/path-key"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export const HOME_SESSION_LIMIT = 64
// Rows kept per directory from the fetched index: the visible limit plus the
// recent-window search bucket. Merging locally known sessions and pending
// removals into this subset resolves exactly like merging into the complete
// index, so Home shows the same rows, order, and search results while the
// query cache and every per-update re-merge stay bounded by directory count.
export const HOME_SESSION_INDEX_LIMIT = HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT
export async function loadHomeSessionIndex(
list: (
@@ -23,8 +16,7 @@ export async function loadHomeSessionIndex(
) => Promise<SessionsResponse>,
signal?: AbortSignal,
) {
const now = Date.now()
let retained: SessionInfo[] = []
const data: SessionInfo[] = []
let cursor: string | undefined
for (;;) {
@@ -37,11 +29,8 @@ export async function loadHomeSessionIndex(
},
{ signal },
)
// Fold each page in as it arrives: pages are newest first, so later pages
// can only fill directories that still have room. Peak allocation is one
// page plus the retained subset, not the whole history.
retained = retainHomeSessions([...retained, ...parseHomeSessionIndex(response.data)], HOME_SESSION_INDEX_LIMIT, now)
if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return retained
data.push(...response.data)
if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return parseHomeSessionIndex(data)
cursor = response.cursor.next
}
}
+2 -2
View File
@@ -702,11 +702,11 @@ function HomeSessionRow(
>
<Menu.Item onSelect={openEditor}>{props.language.t("common.rename")}</Menu.Item>
<Menu.Item onSelect={() => void props.onExportSession(props.server, props.record.session)}>
{props.language.t("common.export")}
{props.language.t("common.export")}...
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={() => props.onDeleteSession(props.server, props.record.session)}>
{props.language.t("common.delete")}
{props.language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
-5
View File
@@ -32,11 +32,6 @@
}
}
/* iOS can report navigator.standalone without matching display-mode. */
#root[data-standalone="true"] {
height: 100vh;
}
@layer components {
[data-slot="session-side-panel-presence"][data-opened="true"] {
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
@@ -117,13 +117,12 @@ export function createNewSessionComposerAdapter(props: {
{ dir: base64Encode(sessionDirectory), id: created.id },
{ server: server.key, scope: serverSDK.scope },
),
{ preserveDraft: !!pending },
)
})
return {
cleanupReady,
complete: pending ? () => pending.complete(submission.target()) : undefined,
complete: pending?.complete,
session: {
id: created.id,
directory: sessionDirectory,
@@ -195,7 +194,6 @@ async function resolveSessionDirectory(input: {
return createWorktree({
api: input.serverSDK.api,
data: input.data,
directory: input.projectDirectory,
project: input.data.location.info({ directory: input.projectDirectory })?.project,
branch: input.branch,
+3 -14
View File
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { Schema } from "effect"
import { createStore } from "solid-js/store"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import type { ComposerModel } from "@/composer/model"
@@ -20,19 +20,10 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export const WorkspaceOnboardingSchema = Persistence.struct({
used: Schema.Boolean,
})
export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -40,8 +31,7 @@ export function NewSessionView(props: {
}) {
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
{ used: false },
createStore({ used: false }),
)
const select = (value: string) => {
props.workspace.selection.set(value)
@@ -120,8 +110,7 @@ function ProviderTip() {
const providers = useProviders(() => sdk().directory)
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
ProviderTipSchema,
{ dismissedAt: 0 },
createStore({ dismissedAt: 0 }),
)
const visible = createMemo(
() =>
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test"
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
import {
normalizeNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -7,6 +12,8 @@ describe("new session workspace selection", () => {
resolveNewSessionWorktree({
enabled: false,
selected: "/project/feature",
directory: "/project/feature",
projectWorktree: "/project",
}),
).toBe("main")
})
@@ -15,21 +22,31 @@ describe("new session workspace selection", () => {
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "create",
}),
).toBe("create")
expect(
resolveNewSessionWorktree({
enabled: true,
directory: "/project/feature",
projectWorktree: "/project",
fallback: "main",
}),
).toBe("main")
).toBe("/project")
})
test("keeps local selection when the cached project path is stale", () => {
const input = { enabled: true, directory: "C:/Projects/repo", projectWorktree: "D:/Projects/repo" }
expect(resolveNewSessionWorktree(input)).toBe("main")
expect(resolveNewSessionWorktree({ ...input, selected: "/worktree" })).toBe("/worktree")
test("normalizes main to the project root outside the main worktree", () => {
expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
})
test("treats equivalent Windows roots as the main worktree", () => {
expect(
resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" }),
).toBe("main")
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
test("resolves the branch from the active location", () => {
@@ -11,15 +11,27 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: { enabled: boolean; selected?: string; fallback?: string }) {
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
fallback?: string
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
return input.fallback ?? "main"
return normalizeNewSessionWorktree(input.fallback ?? "main", input.directory, input.projectWorktree)
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
if (value === "main" && projectWorktree && !sameDirectory(directory, projectWorktree)) return projectWorktree
return value
}
export function resolveNewSessionBranch(input: {
@@ -80,6 +92,8 @@ export function createNewSessionWorkspaceController(input: {
resolveNewSessionWorktree({
enabled: visible(),
selected: selected(),
directory: sdk().directory,
projectWorktree: currentProject()?.worktree,
fallback: fallback(),
}),
)
@@ -131,7 +145,7 @@ export function createNewSessionWorkspaceController(input: {
remember,
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
remember(worktree)
},
create: (branch: string) => {
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", target: "example" },
state: { status: "active" },
features: { server: true },
},
{
id: "local-plugin",
source: { type: "local", path: "/tmp/plugin.ts" },
state: { status: "active" },
features: { server: true },
},
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
+32 -40
View File
@@ -3,12 +3,10 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
import { useSettings } from "@/settings/model"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { hasCustomAgent, resolveAgent } from "./agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./variant"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -17,49 +15,39 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.optional(Schema.String),
})
export type ModelKey = typeof ModelKeySchema.Type
export type ModelKey = { providerID: string; modelID: string; variant?: string }
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
type State = typeof StateSchema.Type
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
const SessionsSchema = Schema.Record(
Schema.String,
Schema.mutableKey(Persistence.fallback(Schema.UndefinedOr(StateSchema), () => undefined)),
)
const Current = Persistence.struct({ session: SessionsSchema })
export const ModelSelectionSchema = Persistence.migrate(
Current,
Schema.Struct({
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
}).pipe(
Schema.decode({
decode: SchemaGetter.transform((value) => ({
session:
value.session ??
Object.fromEntries(Object.entries(value.pick ?? {}).filter(([key]) => key !== WORKSPACE_KEY)),
})),
encode: SchemaGetter.transform((value) => value),
}),
),
)
type Saved = {
session: Record<string, State | undefined>
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return
return {
@@ -89,9 +77,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
ModelSelectionSchema,
{ session: {} },
{
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
migrate,
},
createStore<Saved>({
session: {},
}),
)
const [store, setStore] = createStore<{
+15 -30
View File
@@ -1,40 +1,25 @@
import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
minimumDuration = 0,
) {
const [tick, setTick] = createSignal(0)
const animation = createMemo<{
identity?: unknown
show: boolean
animate: boolean
value: T | undefined
started: number
}>((previous) => {
tick()
const currentIdentity = identity?.()
const current = value()
const same = !identity || previous?.identity === currentIdentity
const started = same && previous?.show ? previous.started : performance.now()
const remaining =
current === undefined && same && previous?.show ? minimumDuration - (performance.now() - started) : 0
const show = current !== undefined || remaining > 0
if (remaining > 0) {
const timer = setTimeout(() => setTick((value) => value + 1), remaining)
onCleanup(() => clearTimeout(timer))
}
return {
identity: currentIdentity,
show,
started,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
})
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
+39 -39
View File
@@ -6,13 +6,13 @@ export const dict = {
"desktop.menu.go": "ሂድ",
"desktop.menu.window": "መስኮት",
"desktop.menu.help": "እገዛ",
"desktop.menu.checkForUpdates": "ዝማኔዎችን ይመልከቱ",
"desktop.menu.checkForUpdates": "ዝማኔዎችን ይመልከቱ...",
"desktop.menu.settings": "ቅንጅቶች",
"desktop.menu.reloadWebview": "ዳግም ጫን Webview",
"desktop.menu.restart": "ዳግም አስጀምር",
"desktop.menu.exportLogs": "የመላክ ምዝግብ ማስታወሻዎች",
"desktop.menu.exportLogs": "የመላክ ምዝግብ ማስታወሻዎች...",
"desktop.menu.newSession": "አዲስ ክፍለ ጊዜ",
"desktop.menu.openProject": "ክፍት ፕሮጀክት",
"desktop.menu.openProject": "ክፍት ፕሮጀክት...",
"desktop.menu.newWindow": "አዲስ መስኮት",
"desktop.menu.closeWindow": "መስኮትን ዝጋ",
"desktop.menu.undo": "ቀልብስ",
@@ -212,8 +212,8 @@ export const dict = {
"provider.connect.method.apiKey": "API ቁልፍ",
"provider.connect.method.browser": "አሳሽ",
"provider.connect.method.headless": "ያለ ግራፊክ በይነገጽ",
"provider.connect.status.inProgress": "ፍቃድ በሂደት ላይ",
"provider.connect.status.waiting": "ፈቀዳን በመጠበቅ ላይ",
"provider.connect.status.inProgress": "ፍቃድ በሂደት ላይ...",
"provider.connect.status.waiting": "ፈቀዳን በመጠበቅ ላይ...",
"provider.connect.status.failed": "ፍቃድ መስጠት አልተሳካም፡ {{error}}",
"provider.connect.apiKey.description":
"መለያዎን ለማገናኘት የ{{provider}} API ቁልፍዎን ያስገቡ እና {{provider}} ሞዴሎችን በOpenCode ይጠቀሙ።",
@@ -303,7 +303,7 @@ export const dict = {
"common.goBack": "ወደ ኋላ ዳስስ",
"common.goForward": "ወደ ፊት ዳስስ",
"common.loading": "በመጫን ላይ",
"common.loading.ellipsis": "",
"common.loading.ellipsis": "...",
"common.cancel": "ሰርዝ",
"common.clear": "ግልጽ",
"common.open": "ክፍት",
@@ -312,12 +312,12 @@ export const dict = {
"common.continue": "ቀጥል",
"common.submit": "አስረክብ",
"common.save": "አስቀምጥ",
"common.saving": "በማስቀመጥ ላይ",
"common.saving": "በማስቀመጥ ላይ...",
"common.default": "ነባሪ",
"common.attachment": "አባሪ",
"prompt.placeholder.shell": "የሼል ትዕዛዝ አስገባ {{example}}",
"prompt.placeholder.normal": 'ማንኛውንም ነገር ይጠይቁ "{{example}}"',
"prompt.placeholder.simple": "ማንኛውንም ነገር ይጠይቁ",
"prompt.placeholder.shell": "የሼል ትዕዛዝ አስገባ... {{example}}",
"prompt.placeholder.normal": 'ማንኛውንም ነገር ይጠይቁ... "{{example}}"',
"prompt.placeholder.simple": "ማንኛውንም ነገር ይጠይቁ...",
"prompt.placeholder.summarizeComments": "አስተያየቶችን ማጠቃለል…",
"prompt.placeholder.summarizeComment": "አስተያየቱን ማጠቃለል…",
"prompt.mode.shell": "ሼል",
@@ -337,7 +337,7 @@ export const dict = {
"prompt.example.10": "APIሰነድ ፍጠር",
"prompt.example.11": "ዳታቤዝ መጠይቆችን ያመቻቹ",
"prompt.example.12": "የግቤት ማረጋገጫን አክል",
"prompt.example.13": "አዲስ አካል ለ",
"prompt.example.13": "አዲስ አካል ለ...",
"prompt.example.14": "ይህን ፕሮጀክት እንዴት ማሰማራት እችላለሁ?",
"prompt.example.15": "ለምርጥ ልምዶች የእኔን ኮድ ይገምግሙ",
"prompt.example.16": "የስህተት አያያዝን ወደዚህ ተግባር ያክሉ",
@@ -348,7 +348,7 @@ export const dict = {
"prompt.example.21": "የስደት ስክሪፕት እንድጽፍ እርዳኝ",
"prompt.example.22": "ለዚህ የመጨረሻ ነጥብ መሸጎጫ ተግባራዊ ያድርጉ",
"prompt.example.23": "ወደዚህ ዝርዝር ገፅ ጨምር",
"prompt.example.24": "CLI ትዕዛዝ ፍጠር ለ",
"prompt.example.24": "CLI ትዕዛዝ ፍጠር ለ...",
"prompt.example.25": "አካባቢያዊ ተለዋዋጮች እዚህ እንዴት ይሰራሉ?",
"prompt.popover.emptyResults": "ምንም ተዛማጅ ውጤቶች የሉም",
"prompt.popover.emptyCommands": "ምንም ተዛማጅ ትዕዛዞች የሉም",
@@ -400,7 +400,7 @@ export const dict = {
"dialog.directory.parent": "ወላጅ",
"dialog.directory.readError": "ይህን አቃፊ ማንበብ አልተቻለም",
"app.server.unreachable": "{{server}}ን መድረስ አልተቻለም",
"app.server.retrying": "በራስ ሰር እንደገና በመሞከር ላይ",
"app.server.retrying": "በራስ ሰር እንደገና በመሞከር ላይ...",
"app.server.otherServers": "ሌሎች አገልጋዮች",
"dialog.server.title": "አገልጋዮች",
"dialog.server.description": "ይህ መተግበሪያ ከየትኛው OpenCode አገልጋይ ጋር እንደሚገናኝ ይቀይሩ።",
@@ -410,7 +410,7 @@ export const dict = {
"dialog.server.add.url": "የአገልጋይ አድራሻ",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "ከአገልጋይ ጋር መገናኘት አልተቻለም",
"dialog.server.add.checking": "በማጣራት ላይ",
"dialog.server.add.checking": "በማጣራት ላይ...",
"dialog.server.add.button": "አገልጋይ አክል",
"dialog.server.add.name": "የአገልጋይ ስም (አማራጭ)",
"dialog.server.add.namePlaceholder": "Localhost",
@@ -434,26 +434,26 @@ export const dict = {
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL አገልጋይ",
"wsl.server.retryStart": "እንደገና ጀምር",
"wsl.server.updating": "በማዘመን ላይ",
"wsl.server.updating": "በማዘመን ላይ...",
"wsl.onboarding.step.distro": "distro ምረጥ",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "መፈተሽ WSL",
"wsl.onboarding.checkingRuntime": "መፈተሽ WSL...",
"wsl.onboarding.restartRequired": "Windows WSLን መጫኑን ለመጨረስ ዳግም መጀመር ያስፈልገዋል።",
"wsl.onboarding.ready": "WSL ዝግጁ ነው።",
"wsl.onboarding.required": "WSLለመቀጠል ያስፈልጋል።",
"wsl.onboarding.checkingDistros": "Distros በመፈተሽ ላይ",
"wsl.onboarding.installingDistro": "በመጫን ላይ {{distro}}",
"wsl.onboarding.checkingDistro": "መፈተሽ {{distro}}",
"wsl.onboarding.listingDistros": "የዝርዝር ዲስትሮስ",
"wsl.onboarding.checkingDistros": "Distros በመፈተሽ ላይ...",
"wsl.onboarding.installingDistro": "በመጫን ላይ {{distro}}...",
"wsl.onboarding.checkingDistro": "መፈተሽ {{distro}}...",
"wsl.onboarding.listingDistros": "የዝርዝር ዲስትሮስ...",
"wsl.onboarding.distroReady": "{{distro}} ዝግጁ ነው።",
"wsl.onboarding.distroNotInstalled": "{{distro}} ገና አልተጫነም።",
"wsl.onboarding.openDistroOnce": "ማዋቀሩን ለመጨረስ {{distro}} አንድ ጊዜ ይክፈቱ።",
"wsl.onboarding.finishingDistro": "ማዋቀሩን ለ{{distro}} በማጠናቀቅ ላይ።",
"wsl.onboarding.pickDistro": "distro ይምረጡ ወይም ከታች ይጫኑ።",
"wsl.onboarding.checkingOpencode": "መፈተሽ OpenCode",
"wsl.onboarding.checkingOpencodeIn": "OpenCodeን በ{{distro}} ውስጥ በመፈተሽ ላይ",
"wsl.onboarding.updatingOpencode": "በማዘመን ላይ OpenCode",
"wsl.onboarding.updatingOpencodeIn": "በማዘመን ላይ OpenCode በ{{distro}}",
"wsl.onboarding.checkingOpencode": "መፈተሽ OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "OpenCodeን በ{{distro}} ውስጥ በመፈተሽ ላይ...",
"wsl.onboarding.updatingOpencode": "በማዘመን ላይ OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "በማዘመን ላይ OpenCode በ{{distro}}...",
"wsl.onboarding.updateOpencodeIn": "አዘምን OpenCode በ{{distro}}።",
"wsl.onboarding.updateOpencode": "አዘምን OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode በ{{distro}} ውስጥ ዝግጁ ነው።",
@@ -462,11 +462,11 @@ export const dict = {
"wsl.onboarding.installOpencode": "ጫን OpenCode",
"wsl.onboarding.chooseDistroFirst": "መጀመሪያ ዳይስትሮ ይምረጡ።",
"wsl.onboarding.loadFailed": "WSL ሁኔታን መጫን አልተሳካም።",
"wsl.onboarding.loading": "በመጫን ላይ",
"wsl.onboarding.loading": "በመጫን ላይ...",
"wsl.onboarding.installedDistros": "የተጫነው distros",
"wsl.onboarding.checkAgain": "እንደገና ያረጋግጡ",
"wsl.onboarding.distroStatus.ready": "ዝግጁ",
"wsl.onboarding.distroStatus.checking": "በማጣራት ላይ",
"wsl.onboarding.distroStatus.checking": "በማጣራት ላይ...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCodeአልተጫነም",
"wsl.onboarding.distroStatus.missingTools": "የጠፋ bash፣ curl",
"wsl.onboarding.distroStatus.unsupported": "የማይደገፍ · WSL 2] ተጠቀም",
@@ -483,7 +483,7 @@ export const dict = {
"wsl.onboarding.allDistrosAdded": "ሁሉም የተጫኑ ዳይስትሮዎች ቀድሞ ተጨምረዋል።",
"wsl.onboarding.noDistros": "ገና ምንም distros አልተገኘም።",
"wsl.onboarding.install": "ጫን",
"wsl.onboarding.installing": "በመጫን ላይ",
"wsl.onboarding.installing": "በመጫን ላይ...",
"wsl.onboarding.installDistro": "distro ጫን",
"wsl.onboarding.searchDistros": "የፍለጋ distros",
"wsl.onboarding.wsl2Required": "WSL 2 ያስፈልጋል።",
@@ -495,7 +495,7 @@ export const dict = {
"wsl.onboarding.unknown": "ያልታወቀ",
"wsl.onboarding.desktopVersion": "ዴስክቶፕ {{version}}",
"wsl.onboarding.versionMismatch": "የተጫነው ሥሪት ከዴስክቶፕ መተግበሪያ ሥሪት ጋር አይዛመድም።",
"wsl.onboarding.adding": "በማከል ላይ",
"wsl.onboarding.adding": "በማከል ላይ...",
"dialog.project.edit.title": "ፕሮጀክት አርትዕ",
"dialog.project.edit.name": "ስም",
"dialog.project.edit.icon": "አዶ",
@@ -600,7 +600,7 @@ export const dict = {
"error.page.action.report": "ስህተት ሪፖርት አድርግ",
"error.page.action.reported": "ስህተት ሪፖርት ተደርጓል",
"error.page.action.exportLogs": "የመላክ ምዝግብ ማስታወሻዎች",
"error.page.action.checking": "በማጣራት ላይ",
"error.page.action.checking": "በማጣራት ላይ...",
"error.page.action.checkUpdates": "ዝማኔዎችን ይመልከቱ",
"error.page.action.updateTo": "ወደ {{version}} አዘምን",
"error.page.circular": "[Circular]",
@@ -675,12 +675,12 @@ export const dict = {
"session.review.filesChanged": "የተቀየሩ ፋይሎች {{count}}",
"session.review.change.one": "ለውጥ",
"session.review.change.other": "ለውጦች",
"session.review.loadingChanges": "ለውጦችን በመጫን ላይ",
"session.review.loadingChanges": "ለውጦችን በመጫን ላይ...",
"session.review.empty": "በዚህ ክፍለ ጊዜ ምንም ለውጦች የሉም",
"session.review.noVcs": "ምንም Git የስሪት ቁጥጥር ስርዓት አልተገኘም፣ ለውጦች አይታዩም",
"session.review.noVcs.createGit.title": "Git ማከማቻ ፍጠር",
"session.review.noVcs.createGit.description": "በዚህ ፕሮጀክት ላይ ለውጦችን ይከታተሉ፣ ይገምግሙ እና ይቀልብሱ",
"session.review.noVcs.createGit.actionLoading": "Git ማከማቻ በመፍጠር ላይ",
"session.review.noVcs.createGit.actionLoading": "Git ማከማቻ በመፍጠር ላይ...",
"session.review.noVcs.createGit.action": "Git ማከማቻ ፍጠር",
"session.review.noSnapshot": "የቅጽበታዊ ገጽ እይታን መከታተል በውቅረት ውስጥ ተሰናክሏል፣ስለዚህ የክፍለ-ጊዜ ለውጦች አይገኙም",
"session.review.noChanges": "ምንም ለውጦች የሉም",
@@ -691,9 +691,9 @@ export const dict = {
"session.files.empty": "ምንም ፋይሎች",
"session.files.binaryContent": "ሁለትዮሽ ፋይል (ይዘት ሊታይ አይችልም)",
"session.messages.renderEarlier": "ቀደምት መልዕክቶችን ይስሩ",
"session.messages.loadingEarlier": "የቀድሞ መልዕክቶችን በመጫን ላይ",
"session.messages.loadingEarlier": "የቀድሞ መልዕክቶችን በመጫን ላይ...",
"session.messages.loadEarlier": "የቀድሞ መልዕክቶችን ጫን",
"session.messages.loading": "መልእክቶችን በመጫን ላይ",
"session.messages.loading": "መልእክቶችን በመጫን ላይ...",
"session.messages.jumpToLatest": "ወደ የቅርብ ጊዜ ዝለል",
"session.context.addToContext": "{{selection}}ን ወደ አውድ አክል",
"session.todo.title": "የሚደረጉ ሥራዎች",
@@ -754,8 +754,8 @@ export const dict = {
"common.copied": "የተገለበጠ",
"lsp.tooltip.none": "ምንም LSP አገልጋዮች",
"lsp.label.connected": "{{count}}LSP",
"prompt.loading": "ፕሮምፕትን በመጫን ላይ",
"terminal.loading": "በመጫን ላይ ተርሚናል",
"prompt.loading": "ፕሮምፕትን በመጫን ላይ...",
"terminal.loading": "በመጫን ላይ ተርሚናል...",
"terminal.title": "ተርሚናል",
"terminal.title.numbered": "ተርሚናል {{number}}",
"terminal.close": "ተርሚናል ዝጋ",
@@ -908,9 +908,9 @@ export const dict = {
"settings.updates.row.check.title": "ዝማኔዎችን ይመልከቱ",
"settings.updates.row.check.description": "ዝማኔዎችን ለማግኘት በእጅ ያረጋግጡ እና ካለ ይጫኑ",
"settings.updates.action.checkNow": "አሁን ያረጋግጡ",
"settings.updates.action.checking": "በማጣራት ላይ",
"settings.updates.action.downloading": "በማውረድ ላይ",
"settings.updates.action.installing": "በመጫን ላይ",
"settings.updates.action.checking": "በማጣራት ላይ...",
"settings.updates.action.downloading": "በማውረድ ላይ...",
"settings.updates.action.installing": "በመጫን ላይ...",
"settings.updates.toast.latest.title": "ዘመኑን ጠብቀዋል",
"settings.updates.toast.latest.description": "የቅርብ ጊዜውን የOpenCode እትም እያሄዱ ነው።",
"sound.option.none": "ምንም",
@@ -1056,7 +1056,7 @@ export const dict = {
"workspace.reset.success.title": "Workspace ዳግም ተጀምሯል",
"workspace.reset.success.description": "Workspace አሁን ከነባሪው ቅርንጫፍ ጋር ይዛመዳል።",
"workspace.error.stillPreparing": "Workspace አሁንም በመዘጋጀት ላይ ነው",
"workspace.status.checking": "ያልተጣመሩ ለውጦችን በመፈተሽ ላይ",
"workspace.status.checking": "ያልተጣመሩ ለውጦችን በመፈተሽ ላይ...",
"workspace.status.error": "git ሁኔታን ማረጋገጥ አልተቻለም።",
"workspace.status.clean": "ምንም ያልተዋሃዱ ለውጦች አልተገኙም።",
"workspace.status.dirty": "ያልተቀላቀሉ ለውጦች በዚህ workspace ላይ ተገኝተዋል።",
+39 -39
View File
@@ -6,13 +6,13 @@ export const dict = {
"desktop.menu.go": "انتقال",
"desktop.menu.window": "نافذة",
"desktop.menu.help": "مساعدة",
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات",
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
"desktop.menu.settings": "الإعدادات",
"desktop.menu.reloadWebview": "إعادة تحميل Webview",
"desktop.menu.restart": "إعادة التشغيل",
"desktop.menu.exportLogs": "تصدير السجلات",
"desktop.menu.exportLogs": "تصدير السجلات...",
"desktop.menu.newSession": "جلسة جديدة",
"desktop.menu.openProject": "فتح مشروع",
"desktop.menu.openProject": "فتح مشروع...",
"desktop.menu.newWindow": "نافذة جديدة",
"desktop.menu.closeWindow": "إغلاق النافذة",
"desktop.menu.undo": "تراجع",
@@ -219,8 +219,8 @@ export const dict = {
"provider.connect.method.apiKey": "مفتاح API",
"provider.connect.method.browser": "المتصفح",
"provider.connect.method.headless": "بدون واجهة رسومية",
"provider.connect.status.inProgress": "جارٍ التفويض",
"provider.connect.status.waiting": "في انتظار التفويض",
"provider.connect.status.inProgress": "جارٍ التفويض...",
"provider.connect.status.waiting": "في انتظار التفويض...",
"provider.connect.status.failed": "فشل التفويض: {{error}}",
"provider.connect.apiKey.description":
"أدخل مفتاح API الخاص بـ {{provider}} لتوصيل حسابك واستخدام نماذج {{provider}} في OpenCode.",
@@ -313,7 +313,7 @@ export const dict = {
"common.goBack": "رجوع",
"common.goForward": "التقدم",
"common.loading": "جارٍ التحميل",
"common.loading.ellipsis": "",
"common.loading.ellipsis": "...",
"common.cancel": "إلغاء",
"common.clear": "مسح",
"common.connect": "اتصال",
@@ -321,12 +321,12 @@ export const dict = {
"common.continue": "متابعة",
"common.submit": "إرسال",
"common.save": "حفظ",
"common.saving": "جارٍ الحفظ",
"common.saving": "جارٍ الحفظ...",
"common.default": "افتراضي",
"common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell {{example}}",
"prompt.placeholder.normal": 'اسأل أي شيء "{{example}}"',
"prompt.placeholder.simple": "اسأل أي شيء",
"prompt.placeholder.shell": "أدخل أمر shell... {{example}}",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
"prompt.placeholder.simple": "اسأل أي شيء...",
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell",
@@ -346,7 +346,7 @@ export const dict = {
"prompt.example.10": "توليد وثائق API",
"prompt.example.11": "تحسين استعلامات قاعدة البيانات",
"prompt.example.12": "إضافة التحقق من صحة الإدخال",
"prompt.example.13": "إنشاء مكون جديد لـ",
"prompt.example.13": "إنشاء مكون جديد لـ...",
"prompt.example.14": "كيف أقوم بنشر هذا المشروع؟",
"prompt.example.15": "مراجعة الكود الخاص بي لأفضل الممارسات",
"prompt.example.16": "إضافة معالجة الأخطاء لهذه الدالة",
@@ -357,7 +357,7 @@ export const dict = {
"prompt.example.21": "ساعدني في كتابة برنامج نصي للترحيل",
"prompt.example.22": "تنفيذ التخزين المؤقت لنقطة النهاية هذه",
"prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة",
"prompt.example.24": "إنشاء أمر CLI لـ",
"prompt.example.24": "إنشاء أمر CLI لـ...",
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
@@ -416,7 +416,7 @@ export const dict = {
"dialog.server.add.url": "عنوان URL للخادم",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "تعذر الاتصال بالخادم",
"dialog.server.add.checking": "جارٍ التحقق",
"dialog.server.add.checking": "جارٍ التحقق...",
"dialog.server.add.button": "إضافة خادم",
"dialog.server.add.name": "اسم الخادم (اختياري)",
"dialog.server.add.namePlaceholder": "Localhost",
@@ -440,26 +440,26 @@ export const dict = {
"wsl.server.label": "WSL",
"wsl.server.menu.label": "خادم WSL",
"wsl.server.retryStart": "إعادة محاولة التشغيل",
"wsl.server.updating": "جارٍ التحديث",
"wsl.server.updating": "جارٍ التحديث...",
"wsl.onboarding.step.distro": "اختر توزيعة",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "جارٍ التحقق من WSL",
"wsl.onboarding.checkingRuntime": "جارٍ التحقق من WSL...",
"wsl.onboarding.restartRequired": "يحتاج Windows إلى إعادة التشغيل لإكمال تثبيت WSL.",
"wsl.onboarding.ready": "WSL جاهز.",
"wsl.onboarding.required": "يلزم WSL للمتابعة.",
"wsl.onboarding.checkingDistros": "جارٍ التحقق من التوزيعات",
"wsl.onboarding.installingDistro": "جارٍ تثبيت {{distro}}",
"wsl.onboarding.checkingDistro": "جارٍ التحقق من {{distro}}",
"wsl.onboarding.listingDistros": "جارٍ سرد التوزيعات",
"wsl.onboarding.checkingDistros": "جارٍ التحقق من التوزيعات...",
"wsl.onboarding.installingDistro": "جارٍ تثبيت {{distro}}...",
"wsl.onboarding.checkingDistro": "جارٍ التحقق من {{distro}}...",
"wsl.onboarding.listingDistros": "جارٍ سرد التوزيعات...",
"wsl.onboarding.distroReady": "{{distro}} جاهزة.",
"wsl.onboarding.distroNotInstalled": "لم يتم تثبيت {{distro}} بعد.",
"wsl.onboarding.openDistroOnce": "افتح {{distro}} مرة واحدة لإكمال الإعداد.",
"wsl.onboarding.finishingDistro": "جارٍ إكمال إعداد {{distro}}.",
"wsl.onboarding.pickDistro": "اختر توزيعة أو ثبّت واحدة أدناه.",
"wsl.onboarding.checkingOpencode": "جارٍ التحقق من OpenCode",
"wsl.onboarding.checkingOpencodeIn": "جارٍ التحقق من OpenCode في {{distro}}",
"wsl.onboarding.updatingOpencode": "جارٍ تحديث OpenCode",
"wsl.onboarding.updatingOpencodeIn": "جارٍ تحديث OpenCode في {{distro}}",
"wsl.onboarding.checkingOpencode": "جارٍ التحقق من OpenCode...",
"wsl.onboarding.checkingOpencodeIn": "جارٍ التحقق من OpenCode في {{distro}}...",
"wsl.onboarding.updatingOpencode": "جارٍ تحديث OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "جارٍ تحديث OpenCode في {{distro}}...",
"wsl.onboarding.updateOpencodeIn": "حدّث OpenCode في {{distro}}.",
"wsl.onboarding.updateOpencode": "تحديث OpenCode",
"wsl.onboarding.opencodeReadyIn": "OpenCode جاهز في {{distro}}.",
@@ -468,11 +468,11 @@ export const dict = {
"wsl.onboarding.installOpencode": "تثبيت OpenCode",
"wsl.onboarding.chooseDistroFirst": "اختر توزيعة أولاً.",
"wsl.onboarding.loadFailed": "فشل تحميل حالة WSL.",
"wsl.onboarding.loading": "جارٍ التحميل",
"wsl.onboarding.loading": "جارٍ التحميل...",
"wsl.onboarding.installedDistros": "التوزيعات المثبتة",
"wsl.onboarding.checkAgain": "تحقق مجددًا",
"wsl.onboarding.distroStatus.ready": "جاهزة",
"wsl.onboarding.distroStatus.checking": "جارٍ التحقق",
"wsl.onboarding.distroStatus.checking": "جارٍ التحقق...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode غير مثبت",
"wsl.onboarding.distroStatus.missingTools": "bash و curl مفقودان",
"wsl.onboarding.distroStatus.unsupported": "غير مدعومة · استخدم WSL 2",
@@ -490,7 +490,7 @@ export const dict = {
"wsl.onboarding.allDistrosAdded": "تمت إضافة جميع التوزيعات المثبتة بالفعل.",
"wsl.onboarding.noDistros": "لم يتم اكتشاف أي توزيعات بعد.",
"wsl.onboarding.install": "تثبيت",
"wsl.onboarding.installing": "جارٍ التثبيت",
"wsl.onboarding.installing": "جارٍ التثبيت...",
"wsl.onboarding.installDistro": "تثبيت توزيعة",
"wsl.onboarding.searchDistros": "البحث عن توزيعات",
"wsl.onboarding.wsl2Required": "يلزم WSL 2.",
@@ -502,7 +502,7 @@ export const dict = {
"wsl.onboarding.unknown": "غير معروف",
"wsl.onboarding.desktopVersion": "سطح المكتب {{version}}",
"wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.",
"wsl.onboarding.adding": "جارٍ الإضافة",
"wsl.onboarding.adding": "جارٍ الإضافة...",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة",
@@ -606,7 +606,7 @@ export const dict = {
"error.page.action.report": "الإبلاغ عن الخطأ",
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
"error.page.action.exportLogs": "تصدير السجلات",
"error.page.action.checking": "جارٍ التحقق",
"error.page.action.checking": "جارٍ التحقق...",
"error.page.action.checkUpdates": "التحقق من وجود تحديثات",
"error.page.action.updateTo": "تحديث إلى {{version}}",
"error.page.report.prefix": "يرجى الإبلاغ عن هذا الخطأ لفريق OpenCode",
@@ -674,7 +674,7 @@ export const dict = {
"session.review.filesChanged": "الملفات المتغيرة: {{count}}",
"session.review.change.one": "تغيير",
"session.review.change.other": "تغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات",
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
"session.review.noChanges": "لا توجد تغييرات",
"session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد",
@@ -686,9 +686,9 @@ export const dict = {
"session.files.empty": "لا توجد ملفات",
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
"session.messages.loading": "جارٍ تحميل الرسائل",
"session.messages.loading": "جارٍ تحميل الرسائل...",
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
"session.todo.title": "المهام",
@@ -732,8 +732,8 @@ export const dict = {
"common.copied": "تم النسخ",
"lsp.tooltip.none": "لا توجد خوادم LSP",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "جارٍ تحميل الموجه",
"terminal.loading": "جارٍ تحميل المحطة الطرفية",
"prompt.loading": "جارٍ تحميل الموجه...",
"terminal.loading": "جارٍ تحميل المحطة الطرفية...",
"terminal.title": "محطة طرفية",
"terminal.title.numbered": "محطة طرفية {{number}}",
"terminal.close": "إغلاق المحطة الطرفية",
@@ -845,9 +845,9 @@ export const dict = {
"settings.updates.row.check.title": "التحقق من التحديثات",
"settings.updates.row.check.description": "التحقق يدويًا من التحديثات وتثبيتها إذا كانت متاحة",
"settings.updates.action.checkNow": "تحقق الآن",
"settings.updates.action.checking": "جارٍ التحقق",
"settings.updates.action.downloading": "جارٍ التنزيل",
"settings.updates.action.installing": "جارٍ التثبيت",
"settings.updates.action.checking": "جارٍ التحقق...",
"settings.updates.action.downloading": "جارٍ التنزيل...",
"settings.updates.action.installing": "جارٍ التثبيت...",
"settings.updates.toast.latest.title": "لديك أحدث إصدار",
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
"sound.option.none": "بلا",
@@ -991,7 +991,7 @@ export const dict = {
"workspace.reset.success.title": "تمت إعادة تعيين مساحة العمل",
"workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.",
"workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد",
"workspace.status.checking": "التحقق من التغييرات غير المدمجة",
"workspace.status.checking": "التحقق من التغييرات غير المدمجة...",
"workspace.status.error": "تعذر التحقق من حالة Git.",
"workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.",
"workspace.status.dirty": "تم اكتشاف تغييرات غير مدمجة في مساحة العمل هذه.",
@@ -1021,12 +1021,12 @@ export const dict = {
"settings.providers.custom.description": "أضف موفرًا متوافقًا مع OpenAI باستخدام عنوان URL الأساسي.",
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا",
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى",
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git",
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...",
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
"session.question.progress": "{{current}} من {{total}} أسئلة",

Some files were not shown because too many files have changed in this diff Show More