Compare commits

..
Author SHA1 Message Date
Kit Langton 583103f2dd test(core): limit process group survival check to POSIX
Node assigns non-detached Windows children to a kill-on-parent-exit job, so the held-stdio mcp fixture cannot assert POSIX process-group survival there. Keep detached descendant, capture deadline, and success-policy coverage enabled on Windows.
2026-08-31 15:35:32 -04:00
Kit Langton acaca2cc01 fix(util): separate process exit from capture completion
Report exit and running state from the child exit signal, independently of buffered output. On scope release, discard abandoned capture and retain the existing pipe-close or capture-deadline wait before applying process-group cleanup policy.

Cover unread output after confirmed process exit, successful descendant survival, and the approved policy that a parent exiting successfully before its invocation timeout retains success while the bounded capture grace finishes.
2026-08-31 15:12:01 -04:00
Kit Langton ef8b8c1b8d fix(util): preserve process output for late readers
Buffer child stdout and stderr before Effect consumers attach, retaining stream backpressure and scoped cleanup. Detach capture buffers before the existing post-exit discard deadline drains inherited pipes.

Cover post-exit readers, output larger than the buffers, and teardown with unread stdout through the real process spawner.
2026-08-31 14:55:04 -04:00
778 changed files with 21050 additions and 30638 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 -26
View File
@@ -418,12 +418,11 @@
"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:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -593,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": [
@@ -731,7 +730,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
},
"packages/simulation": {
@@ -971,7 +969,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -981,7 +978,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -1051,7 +1047,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",
@@ -1081,9 +1076,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",
@@ -1598,7 +1593,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=="],
@@ -2224,27 +2219,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=="],
@@ -3216,6 +3211,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=="],
@@ -5454,6 +5451,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=="],
@@ -5668,6 +5667,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=="],
@@ -5816,7 +5817,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=="],
@@ -5848,6 +5851,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=="],
@@ -6258,8 +6263,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=="],
@@ -6604,6 +6607,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-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
}
}
+6 -7
View File
@@ -5,10 +5,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -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"
}
}
+46 -64
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type HostedToolReplayItem = {
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface ProviderAdapter {
export interface Extension {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface ProviderAdapter {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,9 +397,6 @@ export interface ParserState {
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -485,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -510,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
@@ -529,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
"tool-result",
)
})
@@ -543,33 +540,30 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -577,13 +571,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -650,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: adapter.restoreHostedToolItem?.(part.result.value)
: extension.lowerHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -664,11 +658,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -681,11 +677,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, adapter),
output: yield* lowerToolResultOutput(part, request, extension),
})
}
}
@@ -737,28 +733,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(request, extension),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
adapter.name,
extension.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -772,7 +768,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
@@ -955,16 +951,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -977,7 +969,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1094,12 +1085,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1112,13 +1098,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1257,12 +1243,9 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (
item.type !== "function_call" ||
!item.call_id ||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
)
continue
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1425,9 +1408,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1435,7 +1418,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -216,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -289,44 +233,9 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +4032,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -1,33 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
story("centers add menu shortcuts in a consistent column", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
await component.locator('[data-action="composer-attach"]').click()
const shortcuts = page.locator('[role="menu"] [data-slot="menu-v2-item-shortcut"]')
await expect(shortcuts).toHaveCount(4)
const boxes = await shortcuts.evaluateAll((items) =>
items.map((item) => {
const box = item.getBoundingClientRect()
return { width: box.width, center: box.left + box.width / 2 }
}),
)
expect(new Set(boxes.map((box) => box.width)).size).toBe(1)
expect(new Set(boxes.map((box) => box.center)).size).toBe(1)
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -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,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,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()
},
)
}
@@ -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
}
@@ -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()
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,8 +131,10 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -142,6 +144,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
}
@@ -49,8 +49,8 @@ test("renames, exports, and deletes a home session from its context menu", async
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
@@ -88,13 +88,13 @@ test("renames, exports, and deletes a home session from its context menu", async
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export" })
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete" }).click()
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
@@ -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()
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
@@ -42,7 +42,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New 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 }
}
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,8 +88,10 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -99,7 +101,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -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", package: id },
state: { status: "active" },
features: { server: true },
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -6,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))
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID, tabKey }) => {
({ directory, server, sessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,8 +69,10 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -80,7 +82,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -93,18 +95,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" })
@@ -22,7 +22,6 @@ test("restores review mode and selected file per session", async ({ page }) => {
await selectFile(page, "alpha.ts")
await switchSession(page, titleB)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
@@ -18,72 +18,37 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The review pane's data is workspace-scoped, but visibility belongs to each
// session tab. Switching tabs must keep the pane mounted without opening it.
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toContain("0.24s")
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(review).toBeHidden()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toBe("0s")
expect(await readProbe(page)).toBe(PROBE)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "520px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await expect
.poll(() =>
page.evaluate(
({ key }) => {
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
return panes[key]?.review
},
{ key: `${server}\n${sessionHref(sessionA)}` },
),
)
.toBe(true)
await page.reload()
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
@@ -137,7 +102,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions, panes }) => {
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -149,19 +114,8 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
@@ -123,17 +123,12 @@ test("uses side placement by default and supports the terminal across the bottom
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(
({ tabKey, server, sessionID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
@@ -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", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
],
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
plugins.resolve()
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
@@ -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" }, 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"]')
@@ -252,15 +252,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs.panes",
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
localStorage.setItem(
terminalKey,
JSON.stringify({
@@ -269,13 +262,7 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{
terminalKey: terminalStorageKey(),
ptyID,
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
server,
sessionID,
},
{ terminalKey: terminalStorageKey(), ptyID },
)
}
@@ -16,9 +16,10 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminal processes are workspace-scoped, but panel visibility belongs to each
// session tab. Switching tabs must keep the PTY alive without opening its panel.
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
@@ -26,10 +27,7 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
const terminalPanel = page.locator('[data-component="terminal-panel"]')
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
await expect(terminalPanel).toHaveCSS("height", "300px")
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
@@ -39,26 +37,15 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeHidden()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
await expect(terminal).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "180px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.reload()
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -133,7 +120,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions, panes }) => {
({ directory, server, sessions }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -145,20 +132,8 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
return connections
}
@@ -75,12 +75,11 @@ for (const theme of ["light", "dark"] as const) {
await expectBackground(view.send, "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectToken(
await expectBackground(
message,
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -134,8 +133,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")
@@ -246,16 +245,3 @@ async function expectBackground(element: Locator, token: string, property = "bac
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
async function expectToken(element: Locator, property: string, token: string) {
const color = await element.evaluate((element, token) => {
const probe = document.createElement("span")
probe.hidden = true
probe.style.color = `var(${token})`
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, color)
}
@@ -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))
@@ -1,7 +1,3 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+7 -8
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()}
@@ -114,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
@@ -185,7 +187,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 +240,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 +557,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,
-10
View File
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
-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),
+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", package: "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"])
})
})
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+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<{
+6 -30
View File
@@ -1,38 +1,14 @@
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?.()
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
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))
}
const show = current !== undefined
return {
identity: currentIdentity,
show,
started,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
const presence = createPresence({ show: () => animation().show, element })
+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}} أسئلة",
+39 -39
View File
@@ -6,13 +6,13 @@ export const dict = {
"desktop.menu.go": "Keçid",
"desktop.menu.window": "Pəncərə",
"desktop.menu.help": "Kömək",
"desktop.menu.checkForUpdates": "Yeniləmələri yoxla",
"desktop.menu.checkForUpdates": "Yeniləmələri yoxla...",
"desktop.menu.settings": "Tənzimləmələr",
"desktop.menu.reloadWebview": "Webview-u yenidən yüklə",
"desktop.menu.restart": "Yenidən başlat",
"desktop.menu.exportLogs": "Jurnalları ixrac et",
"desktop.menu.exportLogs": "Jurnalları ixrac et...",
"desktop.menu.newSession": "Yeni sessiya",
"desktop.menu.openProject": "Layihəni aç",
"desktop.menu.openProject": "Layihəni aç...",
"desktop.menu.newWindow": "Yeni pəncərə",
"desktop.menu.closeWindow": "Pəncərəni bağla",
"desktop.menu.undo": "Geri al",
@@ -215,8 +215,8 @@ export const dict = {
"provider.connect.method.apiKey": "API açarı",
"provider.connect.method.browser": "Brauzer",
"provider.connect.method.headless": "Başsız",
"provider.connect.status.inProgress": "Avtorizasiya davam edir",
"provider.connect.status.waiting": "Avtorizasiya gözlənilir",
"provider.connect.status.inProgress": "Avtorizasiya davam edir...",
"provider.connect.status.waiting": "Avtorizasiya gözlənilir...",
"provider.connect.status.failed": "Avtorizasiya uğursuz oldu: {{error}}",
"provider.connect.apiKey.description":
"Hesabınızı qoşmaq və OpenCode-da {{provider}} modellərindən istifadə etmək üçün {{provider}} API açarınızı daxil edin.",
@@ -310,7 +310,7 @@ export const dict = {
"common.goBack": "Geri keç",
"common.goForward": "İrəli keç",
"common.loading": "Yüklənir",
"common.loading.ellipsis": "",
"common.loading.ellipsis": "...",
"common.cancel": "Ləğv et",
"common.clear": "Təmizlə",
"common.open": "Aç",
@@ -319,12 +319,12 @@ export const dict = {
"common.continue": "Davam et",
"common.submit": "Göndər",
"common.save": "Saxla",
"common.saving": "Saxlanılır",
"common.saving": "Saxlanılır...",
"common.default": "Standart",
"common.attachment": "əlavə",
"prompt.placeholder.shell": "Qabıq əmrini daxil edin {{example}}",
"prompt.placeholder.normal": 'Nə istəsəniz soruşun "{{example}}"',
"prompt.placeholder.simple": "Nə istəsəniz soruşun",
"prompt.placeholder.shell": "Qabıq əmrini daxil edin... {{example}}",
"prompt.placeholder.normal": 'Nə istəsəniz soruşun... "{{example}}"',
"prompt.placeholder.simple": "Nə istəsəniz soruşun...",
"prompt.placeholder.summarizeComments": "Şərhləri xülasə et…",
"prompt.placeholder.summarizeComment": "Şərhi xülasə et…",
"prompt.mode.shell": "Shell",
@@ -344,7 +344,7 @@ export const dict = {
"prompt.example.10": "API sənədləri yarat",
"prompt.example.11": "Verilənlər bazası sorğularını optimallaşdır",
"prompt.example.12": "Giriş doğrulaması əlavə et",
"prompt.example.13": "Bunun üçün yeni komponent yarat",
"prompt.example.13": "Bunun üçün yeni komponent yarat...",
"prompt.example.14": "Bu layihəni necə yerləşdirə bilərəm?",
"prompt.example.15": "Kodumu qabaqcıl təcrübələr baxımından nəzərdən keçir",
"prompt.example.16": "Bu funksiyaya xəta idarəetməsi əlavə et",
@@ -355,7 +355,7 @@ export const dict = {
"prompt.example.21": "Miqrasiya skripti yazmağıma kömək et",
"prompt.example.22": "Bu endpoint üçün keşləmə tətbiq et",
"prompt.example.23": "Bu siyahıya səhifələmə əlavə et",
"prompt.example.24": "Bunun üçün CLI əmri yarat",
"prompt.example.24": "Bunun üçün CLI əmri yarat...",
"prompt.example.25": "Burada mühit dəyişənləri necə işləyir?",
"prompt.popover.emptyResults": "Uyğun nəticə yoxdur",
"prompt.popover.emptyCommands": "Uyğun əmr yoxdur",
@@ -407,7 +407,7 @@ export const dict = {
"dialog.directory.parent": "Üst qovluq",
"dialog.directory.readError": "Bu qovluğu oxumaq mümkün deyil",
"app.server.unreachable": "{{server}}-ə çatmaq mümkün olmadı",
"app.server.retrying": "Avtomatik olaraq yenidən cəhd edilir",
"app.server.retrying": "Avtomatik olaraq yenidən cəhd edilir...",
"app.server.otherServers": "Digər serverlər",
"dialog.server.title": "Serverlər",
"dialog.server.description": "Bu tətbiqin qoşulduğu OpenCode serverini dəyişdirin.",
@@ -417,7 +417,7 @@ export const dict = {
"dialog.server.add.url": "Server ünvanı",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Serverə qoşulmaq mümkün olmadı",
"dialog.server.add.checking": "Yoxlanılır",
"dialog.server.add.checking": "Yoxlanılır...",
"dialog.server.add.button": "Server əlavə et",
"dialog.server.add.name": "Server adı (istəyə bağlı)",
"dialog.server.add.namePlaceholder": "Localhost",
@@ -442,27 +442,27 @@ export const dict = {
"wsl.server.label": "WSL",
"wsl.server.menu.label": "WSL server",
"wsl.server.retryStart": "Yenidən başlamağa cəhd edin",
"wsl.server.updating": "Yenilənir",
"wsl.server.updating": "Yenilənir...",
"wsl.onboarding.step.distro": "Distribütiv seçin",
"wsl.onboarding.step.opencode": "OpenCode",
"wsl.onboarding.checkingRuntime": "WSL yoxlanılır",
"wsl.onboarding.checkingRuntime": "WSL yoxlanılır...",
"wsl.onboarding.restartRequired":
"WSL-nin quraşdırılmasını başa çatdırmaq üçün Windows-un yenidən başlaması lazımdır.",
"wsl.onboarding.ready": "WSL hazırdır.",
"wsl.onboarding.required": "Davam etmək üçün WSL tələb olunur.",
"wsl.onboarding.checkingDistros": "Distribütivlər yoxlanılır",
"wsl.onboarding.installingDistro": "{{distro}} quraşdırılır",
"wsl.onboarding.checkingDistro": "{{distro}} yoxlanılır",
"wsl.onboarding.listingDistros": "Distribütivlər siyahılanır",
"wsl.onboarding.checkingDistros": "Distribütivlər yoxlanılır...",
"wsl.onboarding.installingDistro": "{{distro}} quraşdırılır...",
"wsl.onboarding.checkingDistro": "{{distro}} yoxlanılır...",
"wsl.onboarding.listingDistros": "Distribütivlər siyahılanır...",
"wsl.onboarding.distroReady": "{{distro}} hazırdır.",
"wsl.onboarding.distroNotInstalled": "{{distro}} hələ quraşdırılmayıb.",
"wsl.onboarding.openDistroOnce": "Quraşdırmanı bitirmək üçün {{distro}}-ni bir dəfə açın.",
"wsl.onboarding.finishingDistro": "{{distro}} üçün quraşdırma tamamlanır.",
"wsl.onboarding.pickDistro": "Distribütiv seçin və ya aşağıdakılardan birini quraşdırın.",
"wsl.onboarding.checkingOpencode": "OpenCode yoxlanılır",
"wsl.onboarding.checkingOpencodeIn": "{{distro}}-də OpenCode yoxlanılır",
"wsl.onboarding.updatingOpencode": "OpenCode yenilənir",
"wsl.onboarding.updatingOpencodeIn": "{{distro}}-də OpenCode yenilənir",
"wsl.onboarding.checkingOpencode": "OpenCode yoxlanılır...",
"wsl.onboarding.checkingOpencodeIn": "{{distro}}-də OpenCode yoxlanılır...",
"wsl.onboarding.updatingOpencode": "OpenCode yenilənir...",
"wsl.onboarding.updatingOpencodeIn": "{{distro}}-də OpenCode yenilənir...",
"wsl.onboarding.updateOpencodeIn": "{{distro}}-də OpenCode-u yeniləyin.",
"wsl.onboarding.updateOpencode": "OpenCode-u yeniləyin",
"wsl.onboarding.opencodeReadyIn": "OpenCode {{distro}}-də hazırdır.",
@@ -471,11 +471,11 @@ export const dict = {
"wsl.onboarding.installOpencode": "OpenCode quraşdırın",
"wsl.onboarding.chooseDistroFirst": "Əvvəlcə distribütiv seçin.",
"wsl.onboarding.loadFailed": "WSL vəziyyətini yükləmək alınmadı.",
"wsl.onboarding.loading": "Yüklənir",
"wsl.onboarding.loading": "Yüklənir...",
"wsl.onboarding.installedDistros": "Quraşdırılmış distribütivlər",
"wsl.onboarding.checkAgain": "Yenidən yoxlayın",
"wsl.onboarding.distroStatus.ready": "Hazır",
"wsl.onboarding.distroStatus.checking": "Yoxlanır",
"wsl.onboarding.distroStatus.checking": "Yoxlanır...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode quraşdırılmayıb",
"wsl.onboarding.distroStatus.missingTools": "Çatışmayan bash, curl",
"wsl.onboarding.distroStatus.unsupported": "Dəstəklənmir · WSL 2 istifadə edin",
@@ -494,7 +494,7 @@ export const dict = {
"wsl.onboarding.allDistrosAdded": "Bütün quraşdırılmış distribütivlər artıq əlavə edilib.",
"wsl.onboarding.noDistros": "Hələ distribütiv aşkarlanmayıb.",
"wsl.onboarding.install": "Quraşdırın",
"wsl.onboarding.installing": "Quraşdırılır",
"wsl.onboarding.installing": "Quraşdırılır...",
"wsl.onboarding.installDistro": "Distribütiv quraşdırın",
"wsl.onboarding.searchDistros": "Distribütivləri axtar",
"wsl.onboarding.wsl2Required": "WSL 2 tələb olunur.",
@@ -506,7 +506,7 @@ export const dict = {
"wsl.onboarding.unknown": "naməlum",
"wsl.onboarding.desktopVersion": "masaüstü {{version}}",
"wsl.onboarding.versionMismatch": "Quraşdırılmış versiya masaüstü proqram versiyasına uyğun gəlmir.",
"wsl.onboarding.adding": "Əlavə edilir",
"wsl.onboarding.adding": "Əlavə edilir...",
"dialog.project.edit.title": "Layihəni redaktə et",
"dialog.project.edit.name": "Ad",
"dialog.project.edit.icon": "İkon",
@@ -615,7 +615,7 @@ export const dict = {
"error.page.action.report": "Xəta bildirin",
"error.page.action.reported": "Xəta bildirildi",
"error.page.action.exportLogs": "Qeydləri ixrac edin",
"error.page.action.checking": "Yoxlanılır",
"error.page.action.checking": "Yoxlanılır...",
"error.page.action.checkUpdates": "Yeniləmələri yoxla",
"error.page.action.updateTo": "{{version}} versiyasına yenilə",
"error.page.circular": "[Dairəvi]",
@@ -695,12 +695,12 @@ export const dict = {
"session.review.filesChanged": "{{count}} fayl dəyişdi",
"session.review.change.one": "Dəyişiklik",
"session.review.change.other": "Dəyişikliklər",
"session.review.loadingChanges": "Dəyişikliklər yüklənir",
"session.review.loadingChanges": "Dəyişikliklər yüklənir...",
"session.review.empty": "Bu sessiyada hələ dəyişiklik yoxdur",
"session.review.noVcs": "Git versiyaya nəzarət sistemi aşkarlanmadı; sessiya dəyişiklikləri göstərilməyəcək",
"session.review.noVcs.createGit.title": "Git repozitoriyası yaradın",
"session.review.noVcs.createGit.description": "Bu layihədə dəyişiklikləri izləyin, nəzərdən keçirin və ləğv edin",
"session.review.noVcs.createGit.actionLoading": "Git repozitoriyası yaradılır",
"session.review.noVcs.createGit.actionLoading": "Git repozitoriyası yaradılır...",
"session.review.noVcs.createGit.action": "Git repozitoriyası yaradın",
"session.review.noSnapshot":
"Snapshot izləmə konfiqurasiyada deaktiv edilib, ona görə də sessiya dəyişiklikləri əlçatan deyil",
@@ -712,9 +712,9 @@ export const dict = {
"session.files.empty": "Fayl yoxdur",
"session.files.binaryContent": "İkili fayl (məzmun göstərilə bilmir)",
"session.messages.renderEarlier": "Əvvəlki mesajları göstər",
"session.messages.loadingEarlier": "Əvvəlki mesajlar yüklənir",
"session.messages.loadingEarlier": "Əvvəlki mesajlar yüklənir...",
"session.messages.loadEarlier": "Əvvəlki mesajları yüklə",
"session.messages.loading": "Mesajlar yüklənir",
"session.messages.loading": "Mesajlar yüklənir...",
"session.messages.jumpToLatest": "Ən sonuncuya keç",
"session.context.addToContext": "{{selection}} kontekstə əlavə et",
"session.todo.title": "Tapşırıqlar",
@@ -775,8 +775,8 @@ export const dict = {
"common.copied": "Kopyalandı",
"lsp.tooltip.none": "LSP server yoxdur",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "Prompt yüklənir",
"terminal.loading": "Terminal yüklənir",
"prompt.loading": "Prompt yüklənir...",
"terminal.loading": "Terminal yüklənir...",
"terminal.title": "Terminal",
"terminal.title.numbered": "Terminal {{number}}",
"terminal.close": "Terminalı bağla",
@@ -941,9 +941,9 @@ export const dict = {
"settings.updates.row.check.title": "Yeniləmələri yoxla",
"settings.updates.row.check.description": "Əl ilə yeniləmələri yoxla və mövcud olduqda quraşdır",
"settings.updates.action.checkNow": "İndi yoxla",
"settings.updates.action.checking": "Yoxlanılır",
"settings.updates.action.downloading": "Yüklənir",
"settings.updates.action.installing": "Quraşdırılır",
"settings.updates.action.checking": "Yoxlanılır...",
"settings.updates.action.downloading": "Yüklənir...",
"settings.updates.action.installing": "Quraşdırılır...",
"settings.updates.toast.latest.title": "Ən son versiyadan istifadə edirsiniz",
"settings.updates.toast.latest.description": "OpenCode-un ən son versiyasından istifadə edirsiniz.",
"sound.option.none": "Yoxdur",
@@ -1090,7 +1090,7 @@ export const dict = {
"workspace.reset.success.title": "İş sahəsi sıfırlandı",
"workspace.reset.success.description": "İş sahəsi indi standart branch ilə uyğunlaşır.",
"workspace.error.stillPreparing": "İş sahəsi hələ hazırlanır",
"workspace.status.checking": "Birləşdirilməmiş dəyişikliklər yoxlanılır",
"workspace.status.checking": "Birləşdirilməmiş dəyişikliklər yoxlanılır...",
"workspace.status.error": "Git statusu doğrulana bilmədi.",
"workspace.status.clean": "Birləşdirilməmiş dəyişiklik aşkarlanmadı.",
"workspace.status.dirty": "Bu iş sahəsində birləşdirilməmiş dəyişikliklər aşkarlandı.",
+41 -41
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": "Отмяна",
@@ -215,8 +215,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.",
@@ -310,7 +310,7 @@ export const dict = {
"common.goBack": "Навигирайте назад",
"common.goForward": "Навигирайте напред",
"common.loading": "Зарежда се",
"common.loading.ellipsis": "",
"common.loading.ellipsis": "...",
"common.cancel": "Отказ",
"common.clear": "ясно",
"common.open": "Отворете",
@@ -319,14 +319,14 @@ 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.summarizeComments": "Обобщете коментарите",
"prompt.placeholder.summarizeComment": "Обобщение на коментара",
"prompt.placeholder.shell": "Въведете команда shell... {{example}}",
"prompt.placeholder.normal": 'Попитайте каквото и да е... "{{example}}"',
"prompt.placeholder.simple": "Питайте нещо...",
"prompt.placeholder.summarizeComments": "Обобщете коментарите...",
"prompt.placeholder.summarizeComment": "Обобщение на коментара...",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "подкана",
"prompt.mode.shell.exit": "esc за изход",
@@ -344,7 +344,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": "Добавете обработка на грешки към тази функция",
@@ -355,7 +355,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": "Няма съответстващи команди",
@@ -407,7 +407,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 сървър се свързва това приложение.",
@@ -417,7 +417,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",
@@ -442,26 +442,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}}.",
@@ -470,11 +470,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",
@@ -493,7 +493,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 е задължително.",
@@ -505,7 +505,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": "Икона",
@@ -612,7 +612,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": "[Кръгло]",
@@ -691,12 +691,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":
"Проследяването на моментна снимка е деактивирано в конфигурацията, така че промените в сесията не са налични",
@@ -708,9 +708,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": "Задачи",
@@ -771,8 +771,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": "Затворете терминала",
@@ -939,9 +939,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": "Няма",
@@ -1091,7 +1091,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": "Открити са необединени промени в това работно пространство.",
+41 -41
View File
@@ -6,13 +6,13 @@ export const dict: Record<string, string> = {
"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": "পূর্বাবস্থায় ফেরান",
@@ -213,8 +213,8 @@ export const dict: Record<string, string> = {
"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 কী লিখুন এবং OpenCode-এ {{provider}} মডেলগুলি ব্যবহার করুন৷",
@@ -307,7 +307,7 @@ export const dict: Record<string, string> = {
"common.goBack": "ফিরে নেভিগেট করুন",
"common.goForward": "সামনে নেভিগেট করুন",
"common.loading": "লোড হচ্ছে",
"common.loading.ellipsis": "",
"common.loading.ellipsis": "...",
"common.cancel": "বাতিল করুন",
"common.clear": "পরিষ্কার",
"common.open": "খোলা",
@@ -316,14 +316,14 @@ export const dict: Record<string, string> = {
"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.summarizeComments": "মন্তব্য সংক্ষিপ্ত করুন",
"prompt.placeholder.summarizeComment": "মন্তব্য সংক্ষিপ্ত করুন",
"prompt.placeholder.shell": "শেল কমান্ড লিখুন... {{example}}",
"prompt.placeholder.normal": 'কিছু জিজ্ঞাসা করুন... "{{example}}"',
"prompt.placeholder.simple": "কিছু জিজ্ঞাসা করুন...",
"prompt.placeholder.summarizeComments": "মন্তব্য সংক্ষিপ্ত করুন...",
"prompt.placeholder.summarizeComment": "মন্তব্য সংক্ষিপ্ত করুন...",
"prompt.mode.shell": "শেল",
"prompt.mode.normal": "প্রম্পট",
"prompt.mode.shell.exit": "প্রস্থান করতে esc",
@@ -341,7 +341,7 @@ export const dict: Record<string, string> = {
"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": "এই ফাংশনে ত্রুটি হ্যান্ডলিং যোগ করুন",
@@ -352,7 +352,7 @@ export const dict: Record<string, string> = {
"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": "কোনো মিলিত কমান্ড নেই",
@@ -404,7 +404,7 @@ export const dict: Record<string, string> = {
"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 সার্ভারের সাথে সংযোগ করে তা পরিবর্তন করুন।",
@@ -414,7 +414,7 @@ export const dict: Record<string, string> = {
"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",
@@ -439,26 +439,26 @@ export const dict: Record<string, string> = {
"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": "WSL ইনস্টল করা শেষ করার জন্য Windows-এর একটি রিস্টার্ট প্রয়োজন।",
"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": "{{distro}} এ OpenCode চেক করা হচ্ছে",
"wsl.onboarding.updatingOpencode": "আপডেট করা হচ্ছে OpenCode",
"wsl.onboarding.updatingOpencodeIn": "{{distro}}-এ OpenCode আপডেট করা হচ্ছে",
"wsl.onboarding.checkingOpencode": "OpenCode পরীক্ষা করা হচ্ছে...",
"wsl.onboarding.checkingOpencodeIn": "{{distro}} এ OpenCode চেক করা হচ্ছে...",
"wsl.onboarding.updatingOpencode": "আপডেট করা হচ্ছে OpenCode...",
"wsl.onboarding.updatingOpencodeIn": "{{distro}}-এ OpenCode আপডেট করা হচ্ছে...",
"wsl.onboarding.updateOpencodeIn": "{{distro}}-এ OpenCode আপডেট করুন।",
"wsl.onboarding.updateOpencode": "OpenCode আপডেট করুন",
"wsl.onboarding.opencodeReadyIn": "OpenCode {{distro}}-এ প্রস্তুত।",
@@ -467,11 +467,11 @@ export const dict: Record<string, string> = {
"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": "অনুপস্থিত বাশ, কার্ল",
"wsl.onboarding.distroStatus.unsupported": "অসমর্থিত · WSL 2 ব্যবহার করুন",
@@ -490,7 +490,7 @@ export const dict: Record<string, string> = {
"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: Record<string, string> = {
"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": "আইকন",
@@ -607,7 +607,7 @@ export const dict: Record<string, string> = {
"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": "[সার্কুলার]",
@@ -685,13 +685,13 @@ export const dict: Record<string, string> = {
"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": "কোনো পরিবর্তন নেই",
@@ -702,9 +702,9 @@ export const dict: Record<string, string> = {
"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": "টোডোস",
@@ -765,8 +765,8 @@ export const dict: Record<string, string> = {
"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": "টার্মিনাল বন্ধ করুন",
@@ -925,9 +925,9 @@ export const dict: Record<string, string> = {
"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": "কোনোটিই নয়",
@@ -1074,7 +1074,7 @@ export const dict: Record<string, string> = {
"workspace.reset.success.title": "ওয়ার্কস্পেস রিসেট",
"workspace.reset.success.description": "ওয়ার্কস্পেস এখন ডিফল্ট শাখার সাথে মেলে।",
"workspace.error.stillPreparing": "ওয়ার্কস্পেস এখনও প্রস্তুত করা হয়",
"workspace.status.checking": "একত্রিত না হওয়া পরিবর্তনগুলি পরীক্ষা করা হচ্ছে",
"workspace.status.checking": "একত্রিত না হওয়া পরিবর্তনগুলি পরীক্ষা করা হচ্ছে...",
"workspace.status.error": "গিট স্ট্যাটাস যাচাই করতে অক্ষম।",
"workspace.status.clean": "কোনো একীভূত পরিবর্তন সনাক্ত করা যায়নি.",
"workspace.status.dirty": "এই ওয়ার্কস্পেসে একত্রিত না হওয়া পরিবর্তন শনাক্ত করা হয়েছে।",

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