mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
12 Commits
pr-43158-assets
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 7188d42b99 | |||
| 9391987443 | |||
| 4d79337d42 | |||
| 401a154ec3 | |||
| 18c25ae406 | |||
| cd156f9f1c | |||
| b968a314d3 | |||
| 93cdfafa78 | |||
| b6a8d99da5 | |||
| 8cacf150db | |||
| 76e7b556b6 | |||
| a888ba4da7 |
@@ -196,7 +196,7 @@ jobs:
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -594,6 +594,7 @@ jobs:
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.build-node-cli.result == 'success'
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.10.2",
|
||||
"vitest": "4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/ai": {
|
||||
@@ -3232,7 +3231,7 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
|
||||
|
||||
@@ -3242,7 +3241,7 @@
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
|
||||
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
||||
|
||||
@@ -3544,7 +3543,7 @@
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
|
||||
|
||||
@@ -3948,7 +3947,7 @@
|
||||
|
||||
"es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
@@ -5620,7 +5619,7 @@
|
||||
|
||||
"tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
@@ -6054,6 +6053,8 @@
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
|
||||
|
||||
"@antfu/install-pkg/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "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-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"@astrojs/cloudflare/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
@@ -6070,8 +6071,6 @@
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
|
||||
|
||||
"@astrojs/mdx/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="],
|
||||
@@ -6470,8 +6469,6 @@
|
||||
|
||||
"@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"@solidjs/start/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],
|
||||
@@ -6542,6 +6539,12 @@
|
||||
|
||||
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
|
||||
|
||||
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
||||
|
||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.127", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.31", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SiD2kyR2J4fEY0FTXYpNznwWN90ohrwzAMi2laZCGkI/lL8DmyJ31zFzMYJvzRusp2IQYmvkYQWOpZpBl01xYw=="],
|
||||
@@ -6580,14 +6583,10 @@
|
||||
|
||||
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
|
||||
|
||||
"astro/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"astro/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"astro/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
|
||||
|
||||
"astro/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||
|
||||
"astro/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "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-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
@@ -6878,10 +6877,6 @@
|
||||
|
||||
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||
|
||||
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||
|
||||
"storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
@@ -6932,11 +6927,15 @@
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
|
||||
"vite-plugin-dynamic-import/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
"vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
|
||||
|
||||
"vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
|
||||
|
||||
"vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
|
||||
|
||||
@@ -7028,6 +7027,8 @@
|
||||
|
||||
"@astrojs/node/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"@astrojs/node/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"@astrojs/node/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"@astrojs/node/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
|
||||
@@ -7046,6 +7047,8 @@
|
||||
|
||||
"@astrojs/node/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
|
||||
"@astrojs/node/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"@astrojs/node/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
|
||||
|
||||
"@astrojs/node/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||
@@ -7082,6 +7085,8 @@
|
||||
|
||||
"@astrojs/vercel/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"@astrojs/vercel/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"@astrojs/vercel/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
|
||||
|
||||
"@astrojs/vercel/astro/get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
|
||||
@@ -7098,6 +7103,8 @@
|
||||
|
||||
"@astrojs/vercel/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
|
||||
"@astrojs/vercel/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"@astrojs/vercel/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
|
||||
|
||||
"@astrojs/vercel/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||
@@ -7456,6 +7463,8 @@
|
||||
|
||||
"@opencode-ai/www/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"@opencode-ai/www/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"@opencode-ai/www/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"@opencode-ai/www/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
|
||||
@@ -7474,6 +7483,8 @@
|
||||
|
||||
"@opencode-ai/www/astro/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
|
||||
"@opencode-ai/www/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"@opencode-ai/www/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
|
||||
|
||||
"@opencode-ai/www/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||
@@ -7582,6 +7593,8 @@
|
||||
|
||||
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||
@@ -7646,6 +7659,8 @@
|
||||
|
||||
"blume/@astrojs/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"blume/@astrojs/mdx/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"blume/@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"blume/@clack/prompts/@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
|
||||
@@ -7676,6 +7691,8 @@
|
||||
|
||||
"blume/astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"blume/astro/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"blume/astro/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"blume/astro/fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
|
||||
@@ -7688,6 +7705,8 @@
|
||||
|
||||
"blume/astro/p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="],
|
||||
|
||||
"blume/astro/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"blume/astro/unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="],
|
||||
|
||||
"blume/astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||
@@ -7824,12 +7843,6 @@
|
||||
|
||||
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
@@ -7902,6 +7915,8 @@
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Q07lZsq4ir+xwAGVfSbY2WNGLrbfWINFaL2I/vTBFrkuXeP7VZh+UtQgLOKZS6Z/6flNFW22jDYdbINvwTV/PA=="],
|
||||
|
||||
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
@@ -8784,8 +8799,6 @@
|
||||
|
||||
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
+1
-2
@@ -121,8 +121,7 @@
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.10.2",
|
||||
"vitest": "4.1.10"
|
||||
"turbo": "2.10.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
|
||||
@@ -957,9 +957,12 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
]
|
||||
}
|
||||
|
||||
const onMessageStop = (state: ParserState): StepResult => {
|
||||
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
|
||||
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
const finished = Lifecycle.finish(lifecycle, events, {
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
@@ -967,8 +970,8 @@ const onMessageStop = (state: ParserState): StepResult => {
|
||||
usage: state.usage,
|
||||
providerMetadata: state.pendingFinish?.providerMetadata,
|
||||
})
|
||||
return [{ ...state, lifecycle }, events]
|
||||
}
|
||||
return [{ ...state, lifecycle: finished, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
||||
// even when the provider message is generic or empty.
|
||||
@@ -992,7 +995,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
|
||||
if (event.type === "message_stop") return onMessageStop(state)
|
||||
if (event.type === "error") return onError(event)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
@@ -955,6 +955,37 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles pending tool calls at message_stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
{ id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -441,6 +441,15 @@ export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
})
|
||||
}
|
||||
|
||||
export function stepStarted(message: SessionMessageAssistant) {
|
||||
return makeEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
})
|
||||
}
|
||||
|
||||
export function userMessage(
|
||||
parts?: PartSeed<"user">[],
|
||||
input: { id?: string; summary?: unknown; created?: number } = {},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
stepStarted,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
@@ -96,8 +97,10 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.send(status("retry"), 180)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(status("busy", 2), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(stepStarted(assistant), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
|
||||
@@ -9,8 +9,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServer } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
@@ -61,13 +60,12 @@ export const DialogFork: Component = () => {
|
||||
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const restored = extractPromptFromParts(
|
||||
normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [],
|
||||
{
|
||||
directory: location().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
},
|
||||
)
|
||||
const message = data.session.message.get(sessionID, item.id)
|
||||
if (message?.type !== "user") return
|
||||
const restored = extractPromptFromMessage(message, {
|
||||
directory: location().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(location().directory)
|
||||
|
||||
serverSDK.api.session
|
||||
@@ -75,7 +73,18 @@ export const DialogFork: Component = () => {
|
||||
.then((forked) => {
|
||||
data.session.remember(forked)
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
const target = prompt.capture({ dir, id: forked.id })
|
||||
target.set(restored)
|
||||
target.context.replaceComments(
|
||||
extractPromptComments(message).map((comment) => ({
|
||||
type: "file",
|
||||
path: comment.path,
|
||||
selection: comment.selection,
|
||||
comment: comment.comment,
|
||||
preview: comment.preview,
|
||||
commentOrigin: comment.origin,
|
||||
})),
|
||||
)
|
||||
navigate(sessionHref(server.key, forked.id))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
|
||||
@@ -82,7 +82,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.name")}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
value={props.name}
|
||||
defaultValue={props.name}
|
||||
disabled={props.busy}
|
||||
onChange={props.onNameChange}
|
||||
onKeyDown={keyDown}
|
||||
@@ -92,7 +92,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.username")}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
value={props.username}
|
||||
defaultValue={props.username}
|
||||
disabled={props.busy}
|
||||
onChange={props.onUsernameChange}
|
||||
onKeyDown={keyDown}
|
||||
@@ -101,7 +101,7 @@ function ServerForm(props: ServerFormProps) {
|
||||
type="password"
|
||||
label={language.t("dialog.server.add.password")}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
value={props.password}
|
||||
defaultValue={props.password}
|
||||
disabled={props.busy}
|
||||
onChange={props.onPasswordChange}
|
||||
onKeyDown={keyDown}
|
||||
|
||||
@@ -407,6 +407,12 @@ describe("prompt submit worktree selection", () => {
|
||||
text: "ls",
|
||||
files: [],
|
||||
agents: [],
|
||||
metadata: {
|
||||
displayText: "ls",
|
||||
comments: [],
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
})
|
||||
|
||||
@@ -138,6 +138,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
metadata: {
|
||||
displayText: request.displayText,
|
||||
comments: request.comments,
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
...input.draft.model,
|
||||
...(input.draft.variant ? { variant: input.draft.variant } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||
|
||||
const user = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
const assistant = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("estimateSessionContextBreakdown", () => {
|
||||
test("estimates tokens and keeps remaining tokens as other", () => {
|
||||
const messages = [user("u1"), assistant("a1")]
|
||||
const parts = {
|
||||
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
|
||||
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
|
||||
}
|
||||
|
||||
const output = estimateSessionContextBreakdown({
|
||||
messages,
|
||||
parts,
|
||||
input: 20,
|
||||
systemPrompt: "system prompt",
|
||||
})
|
||||
|
||||
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
|
||||
expect(map.system).toBe(4)
|
||||
expect(map.user).toBe(3)
|
||||
expect(map.assistant).toBe(5)
|
||||
expect(map.other).toBe(8)
|
||||
})
|
||||
|
||||
test("scales segments when estimates exceed input", () => {
|
||||
const messages = [user("u1"), assistant("a1")]
|
||||
const parts = {
|
||||
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
|
||||
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
|
||||
}
|
||||
|
||||
const output = estimateSessionContextBreakdown({
|
||||
messages,
|
||||
parts,
|
||||
input: 10,
|
||||
systemPrompt: "z".repeat(200),
|
||||
})
|
||||
|
||||
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
|
||||
expect(total).toBeLessThanOrEqual(10)
|
||||
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
|
||||
})
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { Message, Part } from "@/types"
|
||||
|
||||
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||
|
||||
export type SessionContextBreakdownSegment = {
|
||||
key: SessionContextBreakdownKey
|
||||
tokens: number
|
||||
width: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||
|
||||
const charsFromUserPart = (part: Part) => {
|
||||
if (part.type === "text") return part.text.length
|
||||
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||
return 0
|
||||
}
|
||||
|
||||
const charsFromAssistantPart = (part: Part) => {
|
||||
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||
|
||||
const input = Object.keys(part.state.input).length * 16
|
||||
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
|
||||
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
|
||||
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
|
||||
return { assistant: 0, tool: input }
|
||||
}
|
||||
|
||||
const build = (
|
||||
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
|
||||
input: number,
|
||||
) => {
|
||||
return [
|
||||
{
|
||||
key: "system",
|
||||
tokens: tokens.system,
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
tokens: tokens.user,
|
||||
},
|
||||
{
|
||||
key: "assistant",
|
||||
tokens: tokens.assistant,
|
||||
},
|
||||
{
|
||||
key: "tool",
|
||||
tokens: tokens.tool,
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
tokens: tokens.other,
|
||||
},
|
||||
]
|
||||
.filter((x) => x.tokens > 0)
|
||||
.map((x) => ({
|
||||
key: x.key,
|
||||
tokens: x.tokens,
|
||||
width: toPercent(x.tokens, input),
|
||||
percent: toPercentLabel(x.tokens, input),
|
||||
})) as SessionContextBreakdownSegment[]
|
||||
}
|
||||
|
||||
export function estimateSessionContextBreakdown(args: {
|
||||
messages: Message[]
|
||||
parts: Record<string, Part[] | undefined>
|
||||
input: number
|
||||
systemPrompt?: string
|
||||
}) {
|
||||
if (!args.input) return []
|
||||
|
||||
const counts = args.messages.reduce(
|
||||
(acc, msg) => {
|
||||
const parts = args.parts[msg.id] ?? []
|
||||
if (msg.role === "user") {
|
||||
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
|
||||
return { ...acc, user: acc.user + user }
|
||||
}
|
||||
|
||||
if (msg.role !== "assistant") return acc
|
||||
const assistant = parts.reduce(
|
||||
(sum, part) => {
|
||||
const next = charsFromAssistantPart(part)
|
||||
return {
|
||||
assistant: sum.assistant + next.assistant,
|
||||
tool: sum.tool + next.tool,
|
||||
}
|
||||
},
|
||||
{ assistant: 0, tool: 0 },
|
||||
)
|
||||
return {
|
||||
...acc,
|
||||
assistant: acc.assistant + assistant.assistant,
|
||||
tool: acc.tool + assistant.tool,
|
||||
}
|
||||
},
|
||||
{
|
||||
system: args.systemPrompt?.length ?? 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
tool: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const tokens = {
|
||||
system: estimateTokens(counts.system),
|
||||
user: estimateTokens(counts.user),
|
||||
assistant: estimateTokens(counts.assistant),
|
||||
tool: estimateTokens(counts.tool),
|
||||
}
|
||||
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
|
||||
|
||||
if (estimated <= args.input) {
|
||||
return build({ ...tokens, other: args.input - estimated }, args.input)
|
||||
}
|
||||
|
||||
const scale = args.input / estimated
|
||||
const scaled = {
|
||||
system: Math.floor(tokens.system * scale),
|
||||
user: Math.floor(tokens.user * scale),
|
||||
assistant: Math.floor(tokens.assistant * scale),
|
||||
tool: Math.floor(tokens.tool * scale),
|
||||
}
|
||||
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@/types"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
|
||||
cost: number,
|
||||
providerID = "openai",
|
||||
modelID = "gpt-4.1",
|
||||
) => {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
providerID,
|
||||
modelID,
|
||||
cost,
|
||||
tokens: {
|
||||
input: tokens.input,
|
||||
output: tokens.output,
|
||||
reasoning: tokens.reasoning,
|
||||
cache: {
|
||||
read: tokens.read,
|
||||
write: tokens.write,
|
||||
},
|
||||
},
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
const user = (id: string) => {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
cost: 0,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("getSessionContext", () => {
|
||||
test("computes token totals and usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
|
||||
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||
]
|
||||
const providers = [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
"gpt-4.1": {
|
||||
name: "GPT-4.1",
|
||||
limit: { context: 1000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
expect(ctx?.total).toBe(500)
|
||||
expect(ctx?.input).toBe(300)
|
||||
expect(ctx?.usage).toBe(50)
|
||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||
})
|
||||
|
||||
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||
const providers = [{ id: "p-1", models: {} }]
|
||||
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(ctx?.providerLabel).toBe("p-1")
|
||||
expect(ctx?.modelLabel).toBe("m-1")
|
||||
expect(ctx?.limit).toBeUndefined()
|
||||
expect(ctx?.usage).toBeNull()
|
||||
})
|
||||
|
||||
test("recomputes when message array is mutated in place", () => {
|
||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||
const providers = [{ id: "openai", models: {} }]
|
||||
|
||||
const one = getSessionContext(messages, providers)
|
||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||
const two = getSessionContext(messages, providers)
|
||||
|
||||
expect(one?.message.id).toBe("a1")
|
||||
expect(two?.message.id).toBe("a2")
|
||||
})
|
||||
|
||||
test("returns undefined when inputs are undefined", () => {
|
||||
const ctx = getSessionContext(undefined, undefined)
|
||||
|
||||
expect(ctx).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { AssistantMessage, Message } from "@/types"
|
||||
|
||||
type Provider = {
|
||||
id: string
|
||||
name?: string
|
||||
models: Record<string, Model | undefined>
|
||||
}
|
||||
|
||||
type Model = {
|
||||
name?: string
|
||||
limit: {
|
||||
context: number
|
||||
}
|
||||
}
|
||||
|
||||
type Context = {
|
||||
message: AssistantMessage
|
||||
provider?: Provider
|
||||
model?: Model
|
||||
providerLabel: string
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||
}
|
||||
|
||||
const lastAssistantWithTokens = (messages: Message[]) => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
if (tokenTotal(msg) <= 0) continue
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return undefined
|
||||
|
||||
const provider = providers.find((item) => item.id === message.providerID)
|
||||
const model = provider?.models[message.modelID]
|
||||
const limit = model?.limit.context
|
||||
const total = tokenTotal(message)
|
||||
|
||||
return {
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||
return build(messages, providers)
|
||||
}
|
||||
@@ -225,8 +225,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
},
|
||||
)
|
||||
|
||||
const stop = sdk().event.listen((e) => {
|
||||
invalidateFromWatcher(e.details, {
|
||||
const stop = sdk().event.on("filesystem.changed", (event) => {
|
||||
invalidateFromWatcher(event, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { invalidateFromWatcher } from "./watcher"
|
||||
|
||||
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
|
||||
|
||||
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
|
||||
id: `evt_${file}`,
|
||||
created: 1,
|
||||
type: "filesystem.changed",
|
||||
data: { file, event },
|
||||
})
|
||||
|
||||
describe("file watcher invalidation", () => {
|
||||
test("reloads open files and refreshes loaded parent on add", () => {
|
||||
const loads: string[] = []
|
||||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: (path) => path === "src/new.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => undefined,
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src/new.ts", "add"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: (path) => path === "src/new.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => undefined,
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
expect(loads).toEqual(["src/new.ts"])
|
||||
expect(refresh).toEqual(["src"])
|
||||
@@ -30,30 +31,21 @@ describe("file watcher invalidation", () => {
|
||||
test("reloads files that are open in tabs", () => {
|
||||
const loads: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
isOpen: (path) => path === "src/open.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => ({
|
||||
path: "src/open.ts",
|
||||
type: "file",
|
||||
name: "open.ts",
|
||||
absolute: "/repo/src/open.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => false,
|
||||
refreshDir: () => {},
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src/open.ts", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
isOpen: (path) => path === "src/open.ts",
|
||||
loadFile: (path) => loads.push(path),
|
||||
node: () => ({
|
||||
path: "src/open.ts",
|
||||
type: "file",
|
||||
name: "open.ts",
|
||||
absolute: "/repo/src/open.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => false,
|
||||
refreshDir: () => {},
|
||||
})
|
||||
|
||||
expect(loads).toEqual(["src/open.ts"])
|
||||
})
|
||||
@@ -61,47 +53,29 @@ describe("file watcher invalidation", () => {
|
||||
test("refreshes only changed loaded directory nodes", () => {
|
||||
const refresh: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
|
||||
isDirLoaded: (path) => path === "src",
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({
|
||||
path: "src/file.ts",
|
||||
type: "file",
|
||||
name: "file.ts",
|
||||
absolute: "/repo/src/file.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
invalidateFromWatcher(filesystemEvent("src/file.ts", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => ({
|
||||
path: "src/file.ts",
|
||||
type: "file",
|
||||
name: "file.ts",
|
||||
absolute: "/repo/src/file.ts",
|
||||
ignored: false,
|
||||
}),
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
expect(refresh).toEqual(["src"])
|
||||
})
|
||||
@@ -109,40 +83,16 @@ describe("file watcher invalidation", () => {
|
||||
test("ignores invalid or git watcher updates", () => {
|
||||
const refresh: string[] = []
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
},
|
||||
invalidateFromWatcher(filesystemEvent(".git/index.lock", "change"), {
|
||||
normalize: (input) => input,
|
||||
hasFile: () => true,
|
||||
loadFile: () => {
|
||||
throw new Error("should not load")
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => true,
|
||||
loadFile: () => {
|
||||
throw new Error("should not load")
|
||||
},
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "project.updated",
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
normalize: (input) => input,
|
||||
hasFile: () => false,
|
||||
loadFile: () => {},
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
},
|
||||
)
|
||||
node: () => undefined,
|
||||
isDirLoaded: () => true,
|
||||
refreshDir: (path) => refresh.push(path),
|
||||
})
|
||||
|
||||
expect(refresh).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FileNode } from "@/types"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
|
||||
type WatcherEvent = {
|
||||
type: string
|
||||
properties: unknown
|
||||
}
|
||||
type WatcherEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
|
||||
|
||||
type WatcherOps = {
|
||||
normalize: (input: string) => string
|
||||
@@ -16,15 +14,7 @@ type WatcherOps = {
|
||||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
const kind = typeof props?.event === "string" ? props.event : undefined
|
||||
if (!rawPath) return
|
||||
if (!kind) return
|
||||
|
||||
const path = ops.normalize(rawPath)
|
||||
const path = ops.normalize(event.data.file)
|
||||
if (!path) return
|
||||
if (path.startsWith(".git/")) return
|
||||
|
||||
@@ -32,20 +22,12 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
ops.loadFile(path)
|
||||
}
|
||||
|
||||
if (kind === "change") {
|
||||
const dir = (() => {
|
||||
if (path === "") return ""
|
||||
const node = ops.node(path)
|
||||
if (node?.type !== "directory") return
|
||||
return path
|
||||
})()
|
||||
if (dir === undefined) return
|
||||
if (!ops.isDirLoaded(dir)) return
|
||||
ops.refreshDir(dir)
|
||||
if (event.data.event === "change") {
|
||||
if (ops.node(path)?.type !== "directory") return
|
||||
if (!ops.isDirLoaded(path)) return
|
||||
ops.refreshDir(path)
|
||||
return
|
||||
}
|
||||
if (kind !== "add" && kind !== "unlink") return
|
||||
|
||||
const parent = path.split("/").slice(0, -1).join("/")
|
||||
if (!ops.isDirLoaded(parent)) return
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Project } from "@/types"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
|
||||
describe("applyGlobalEvent", () => {
|
||||
test("upserts project.updated in sorted position", () => {
|
||||
const projects = [{ id: "b", worktree: "/b" }] as Project[]
|
||||
let next = projects
|
||||
applyGlobalEvent({
|
||||
event: { type: "project.updated", properties: { id: "a", worktree: "/a" } },
|
||||
project: projects,
|
||||
setGlobalProject: (value) => {
|
||||
next = typeof value === "function" ? value(next) : value
|
||||
},
|
||||
refresh() {},
|
||||
})
|
||||
expect(next.map((project) => project.id)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("refreshes on global disposal", () => {
|
||||
let refreshed = false
|
||||
applyGlobalEvent({
|
||||
event: { type: "global.disposed" },
|
||||
project: [],
|
||||
setGlobalProject() {},
|
||||
refresh: () => (refreshed = true),
|
||||
})
|
||||
expect(refreshed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
test("updates vcs and routes refresh events", () => {
|
||||
const [store, setStore] = createStore({ vcs: { branch: "old" } } as State)
|
||||
const pushed: string[] = []
|
||||
let lsp = 0
|
||||
let references = 0
|
||||
const apply = (type: string, properties?: unknown) =>
|
||||
applyDirectoryEvent({
|
||||
event: { type, properties },
|
||||
store,
|
||||
setStore,
|
||||
directory: "/repo",
|
||||
push: (directory) => pushed.push(directory),
|
||||
loadLsp: () => lsp++,
|
||||
loadReferences: () => references++,
|
||||
})
|
||||
|
||||
apply("vcs.branch.updated", { branch: "main" })
|
||||
apply("server.instance.disposed")
|
||||
apply("lsp.updated")
|
||||
apply("reference.updated")
|
||||
|
||||
expect(store.vcs?.branch).toBe("main")
|
||||
expect(pushed).toEqual(["/repo"])
|
||||
expect(lsp).toBe(1)
|
||||
expect(references).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { produce, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Project } from "@/types"
|
||||
import type { State, VcsCache } from "./types"
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
|
||||
refresh: () => void
|
||||
}) {
|
||||
if (input.event.type === "global.disposed") {
|
||||
input.refresh()
|
||||
return
|
||||
}
|
||||
if (input.event.type !== "project.updated") return
|
||||
const properties = input.event.properties as Project
|
||||
const result = Binary.search(input.project, properties.id, (project) => project.id)
|
||||
if (result.found) {
|
||||
input.setGlobalProject(
|
||||
produce((draft) => {
|
||||
draft[result.index] = { ...draft[result.index], ...properties }
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
input.setGlobalProject(
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, properties)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function applyDirectoryEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
push: (directory: string) => void
|
||||
directory: string
|
||||
loadLsp: () => void
|
||||
loadReferences?: () => void
|
||||
vcsCache?: VcsCache
|
||||
}) {
|
||||
switch (input.event.type) {
|
||||
case "server.instance.disposed":
|
||||
input.push(input.directory)
|
||||
break
|
||||
case "vcs.branch.updated": {
|
||||
const properties = input.event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === properties.branch) break
|
||||
const next = { ...input.store.vcs, branch: properties.branch }
|
||||
input.setStore("vcs", next)
|
||||
input.vcsCache?.setStore("value", next)
|
||||
break
|
||||
}
|
||||
case "lsp.updated":
|
||||
input.loadLsp()
|
||||
break
|
||||
case "reference.updated":
|
||||
input.loadReferences?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,10 @@ function createServerController(
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const data = createData({
|
||||
api: () => sdk.api,
|
||||
event: sdk.event,
|
||||
event: {
|
||||
on: sdk.event.on,
|
||||
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
|
||||
},
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
})
|
||||
|
||||
@@ -3,12 +3,12 @@ import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, o
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import type { EventSessionError } from "@/types"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { useGlobal } from "./global"
|
||||
@@ -32,7 +32,7 @@ type TurnCompleteNotification = NotificationBase & {
|
||||
|
||||
type ErrorNotification = NotificationBase & {
|
||||
type: "error"
|
||||
error: EventSessionError["properties"]["error"]
|
||||
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
|
||||
}
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
@@ -216,8 +216,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
@@ -246,10 +245,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: EventSessionError["properties"] },
|
||||
sessionID: string,
|
||||
error: ErrorNotification["error"],
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
@@ -258,27 +257,25 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = event.properties.error
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
session: sessionID,
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionHref(input.key, sessionID ?? "global")
|
||||
const href = sessionHref(input.key, sessionID)
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = input.sdk.eventByDir.listen((e) => {
|
||||
const event = e.details
|
||||
const unsub = input.sdk.event.listen((event) => {
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.interrupted" &&
|
||||
@@ -286,14 +283,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
|
||||
)
|
||||
return
|
||||
|
||||
const directory = event.current?.location?.directory
|
||||
const directory = event.location?.directory
|
||||
if (!directory) return
|
||||
const time = Date.now()
|
||||
if (event.type === "session.execution.failed") {
|
||||
handleSessionError(directory, event, time)
|
||||
handleSessionError(directory, event.data.sessionID, event.data.error, time)
|
||||
return
|
||||
}
|
||||
handleSessionIdle(directory, event, time)
|
||||
handleSessionIdle(directory, event.data.sessionID, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
|
||||
@@ -54,8 +54,6 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
return Object.values(config).some(isNonAllowRule)
|
||||
}
|
||||
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
||||
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
@@ -204,20 +202,14 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
return next
|
||||
}
|
||||
|
||||
const handlePermission = (e: PermissionEvent) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
void respondPending(event.properties, event.current?.location?.directory)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
||||
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
void respondPending(event.data, event.location?.directory)
|
||||
return
|
||||
}
|
||||
void ready.promise?.then(() => {
|
||||
if (meta.disposed) return
|
||||
handlePermission(event)
|
||||
void respondPending(event.data, event.location?.directory)
|
||||
})
|
||||
})
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import type { FilePartSource } from "@/types"
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
@@ -14,6 +13,19 @@ interface PartBase {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -1,33 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createOpenCodeEventSource } from "./server-sdk"
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves current permission requests", () => {
|
||||
const current = {
|
||||
id: "evt_1",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
data: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
} as OpenCodeEvent
|
||||
const permission = {
|
||||
id: "evt_permission",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
location: { directory: "/repo", workspaceID: "workspace_1" },
|
||||
data: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
id: "evt_1",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm_1",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: ["src/**"],
|
||||
source: { type: "tool", messageID: "msg_1", id: "call_1" },
|
||||
},
|
||||
current,
|
||||
function setup() {
|
||||
return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose }))
|
||||
}
|
||||
|
||||
describe("server event stream", () => {
|
||||
test("publishes the original current event with exact data", () => {
|
||||
const server = setup()
|
||||
const received: OpenCodeEvent[] = []
|
||||
let requestID: string | undefined
|
||||
|
||||
server.event.on("permission.asked", (event) => {
|
||||
requestID = event.data.id
|
||||
})
|
||||
server.event.listen((event) => received.push(event))
|
||||
server.publish(permission)
|
||||
|
||||
expect(requestID).toBe("perm_1")
|
||||
expect(received).toEqual([permission])
|
||||
expect(received[0]).toBe(permission)
|
||||
server.dispose()
|
||||
})
|
||||
|
||||
test("filters locations without changing workspace identity", () => {
|
||||
const server = setup()
|
||||
const repo: OpenCodeEvent[] = []
|
||||
const other: OpenCodeEvent[] = []
|
||||
const all: OpenCodeEvent[] = []
|
||||
let workspaceID: string | undefined
|
||||
const global = {
|
||||
id: "evt_connected",
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "server.connected" }>
|
||||
|
||||
const repoEvents = server.event.location("/repo")
|
||||
repoEvents.on("permission.asked", (event) => {
|
||||
workspaceID = event.location?.workspaceID
|
||||
})
|
||||
repoEvents.listen((event) => repo.push(event))
|
||||
server.event.location("/other").listen((event) => other.push(event))
|
||||
server.event.listen((event) => all.push(event))
|
||||
server.publish(permission)
|
||||
server.publish(global)
|
||||
|
||||
expect(repo).toEqual([permission])
|
||||
expect(workspaceID).toBe("workspace_1")
|
||||
expect(other).toEqual([])
|
||||
expect(all).toEqual([permission, global])
|
||||
server.dispose()
|
||||
})
|
||||
|
||||
test("isolates servers and clears subscriptions with their owner", () => {
|
||||
const first = setup()
|
||||
const second = setup()
|
||||
const received = { first: 0, second: 0 }
|
||||
|
||||
first.event.listen(() => received.first++)
|
||||
second.event.listen(() => received.second++)
|
||||
first.publish(permission)
|
||||
first.dispose()
|
||||
first.publish(permission)
|
||||
second.publish(permission)
|
||||
|
||||
expect(received).toEqual({ first: 1, second: 1 })
|
||||
second.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import type { Event } from "@/types"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
@@ -10,15 +9,52 @@ import { createRefCountMap } from "@/utils/refcount"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
|
||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
export type OpenCodeEventStream = {
|
||||
on<Type extends OpenCodeEvent["type"]>(type: Type, handler: (event: OpenCodeEventMap[Type]) => void): VoidFunction
|
||||
listen(handler: (event: OpenCodeEvent) => void): VoidFunction
|
||||
}
|
||||
|
||||
type OpenCodeEventSource = OpenCodeEventStream & {
|
||||
location(directory: string): OpenCodeEventStream
|
||||
}
|
||||
|
||||
export function createOpenCodeEventSource() {
|
||||
const emitter = createGlobalEmitter<OpenCodeEventMap>()
|
||||
|
||||
function stream(directory?: string): OpenCodeEventStream {
|
||||
return {
|
||||
on(type, handler) {
|
||||
return emitter.on(type, (event) => {
|
||||
if (directory !== undefined && event.location?.directory !== directory) return
|
||||
handler(event)
|
||||
})
|
||||
},
|
||||
listen(handler) {
|
||||
return emitter.listen((event) => {
|
||||
if (directory !== undefined && event.details.location?.directory !== directory) return
|
||||
handler(event.details)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const event: OpenCodeEventSource = {
|
||||
...stream(),
|
||||
location: (directory) => stream(directory),
|
||||
}
|
||||
|
||||
onCleanup(() => emitter.clear())
|
||||
|
||||
return {
|
||||
event,
|
||||
publish(event: OpenCodeEvent) {
|
||||
emitter.emit(event.type, event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
||||
export type ServerConnectionStatus = ClientConnectionStatus
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
@@ -30,28 +66,19 @@ type ServerSDKBase = {
|
||||
attempt: Accessor<number>
|
||||
error: Accessor<string | undefined>
|
||||
}
|
||||
eventByDir: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
}
|
||||
event: {
|
||||
on: CurrentEventEmitter["on"]
|
||||
listen: CurrentEventEmitter["listen"]
|
||||
}
|
||||
event: OpenCodeEventSource
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
||||
const emitter = createGlobalEmitter<CurrentEventMap>()
|
||||
const events = createOpenCodeEventSource()
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
emitter.emit(event.type, event)
|
||||
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
||||
events.publish(event)
|
||||
},
|
||||
log: {
|
||||
info(message, data) {
|
||||
@@ -61,25 +88,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
},
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
dirEmitter.clear()
|
||||
emitter.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
connection,
|
||||
eventByDir: {
|
||||
on: dirEmitter.on.bind(dirEmitter),
|
||||
listen: dirEmitter.listen.bind(dirEmitter),
|
||||
},
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
},
|
||||
event: events.event,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,25 +114,14 @@ export const useServerSDK = () => {
|
||||
return server.ctx.sdk
|
||||
}
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
export type LocationContext = {
|
||||
directory: string
|
||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||
event: OpenCodeEventStream
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.eventByDir.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
directory,
|
||||
event: emitter,
|
||||
event: serverSDK.event.location(directory),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { type ServerSDK } from "./server-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
@@ -219,51 +218,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const unsub = serverSDK.eventByDir.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
connection.handleEvent({ type: eventType })
|
||||
const unsub = serverSDK.event.listen((event) => {
|
||||
connection.handleEvent({ type: event.type })
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: () => void bootstrap.refetch(),
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated")
|
||||
if (!event.location) {
|
||||
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
const directory = event.location.directory
|
||||
const key = directoryKey(directory)
|
||||
if (!children.children[key]) return
|
||||
children.mark(key)
|
||||
if (eventType === "config.updated" || eventType === "agent.updated") queue.push(key)
|
||||
const [store, setStore] = existing
|
||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||
if (eventType !== "vcs.branch.updated")
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
directory,
|
||||
store,
|
||||
setStore,
|
||||
push: (directory) => {
|
||||
if (children.active(directory)) queue.push(directory)
|
||||
},
|
||||
vcsCache: children.vcsCache.get(key),
|
||||
loadLsp: () => {
|
||||
if (!children.active(key)) return
|
||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||
},
|
||||
loadReferences: () => {
|
||||
if (!children.active(key)) return
|
||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||
},
|
||||
})
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
if (event.type === "reference.updated" && children.active(key))
|
||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { messageKey } from "@/utils/session-message"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
let changed = false
|
||||
for (const part of want) {
|
||||
const result = Binary.search(next, part.id, (item) => item.id)
|
||||
if (result.found) continue
|
||||
next.splice(result.index, 0, part)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return parts
|
||||
return next
|
||||
}
|
||||
|
||||
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
||||
const confirmed: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
|
||||
const current = part.get(item.message.id)
|
||||
if (found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
part.set(item.message.id, mergeParts(current, item.parts))
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: page.cursor,
|
||||
complete: page.complete,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageKey(input.message), messageKey)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
|
||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const index = messages.findIndex((message) => message.id === input.messageID)
|
||||
if (index >= 0) messages.splice(index, 1)
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
@@ -215,8 +215,8 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
|
||||
removeExited(event.properties.id)
|
||||
const unsub = sdk.event.on("pty.exited", (event) => {
|
||||
removeExited(event.data.id)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SessionUIProvider(
|
||||
await data.session.sync(sessionID).catch(() => undefined)
|
||||
navigate(href(sessionID))
|
||||
}
|
||||
const legacyData = createMemo(() => ({
|
||||
const sessionUIData = createMemo(() => ({
|
||||
session: data.session.list(),
|
||||
session_status: Object.fromEntries(
|
||||
data.session
|
||||
@@ -32,13 +32,11 @@ export function SessionUIProvider(
|
||||
]),
|
||||
),
|
||||
session_diff: {},
|
||||
message: {},
|
||||
part: {},
|
||||
}))
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={legacyData()}
|
||||
data={sessionUIData()}
|
||||
directory={directory()}
|
||||
sessionID={params.id}
|
||||
onNavigateToSession={navigateToSession}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FilePart, UserMessage } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
@@ -92,7 +92,6 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
@@ -439,11 +438,29 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => lastUserMessage()?.id,
|
||||
() => [lastUserMessage(), controller.data.info()] as const,
|
||||
() => {
|
||||
const msg = lastUserMessage()
|
||||
if (!msg) return
|
||||
syncSessionModel(local, msg)
|
||||
const message = lastUserMessage()
|
||||
const info = controller.data.info()
|
||||
const metadata = message?.metadata
|
||||
const agent = typeof metadata?.agent === "string" ? metadata.agent : info?.agent
|
||||
const model = metadata?.model
|
||||
const selected =
|
||||
model &&
|
||||
typeof model === "object" &&
|
||||
!Array.isArray(model) &&
|
||||
typeof model.providerID === "string" &&
|
||||
typeof model.modelID === "string"
|
||||
? {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
variant: typeof model.variant === "string" ? model.variant : undefined,
|
||||
}
|
||||
: info?.model
|
||||
? { providerID: info.model.providerID, modelID: info.model.id, variant: info.model.variant }
|
||||
: undefined
|
||||
if (!info || !agent || !selected) return
|
||||
syncSessionModel(local, { sessionID: info.id, agent, model: selected })
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -520,8 +537,6 @@ export default function Page() {
|
||||
return open
|
||||
}, desktopReviewOpen())
|
||||
|
||||
// TODO: Restore turn diffs when current transcript projections expose message summaries.
|
||||
const turnDiffs = createMemo(() => list(undefined))
|
||||
const nogit = createMemo(() => {
|
||||
const current = project()
|
||||
return !!current && current.vcs !== "git"
|
||||
@@ -539,7 +554,6 @@ export default function Page() {
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("turn")
|
||||
return list
|
||||
})
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
@@ -602,7 +616,7 @@ export default function Page() {
|
||||
}, 100)
|
||||
onCleanup(
|
||||
sdk().event.listen((event) => {
|
||||
if (event.details.type === "filesystem.changed") refreshVcs()
|
||||
if (event.type === "filesystem.changed") refreshVcs()
|
||||
}),
|
||||
)
|
||||
createEffect(
|
||||
@@ -619,7 +633,8 @@ export default function Page() {
|
||||
if (reviewMode() === "git" || reviewMode() === "branch")
|
||||
// avoids suspense
|
||||
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
|
||||
return turnDiffs()
|
||||
// TODO: Restore turn diffs when the V2 transcript exposes snapshot diffs.
|
||||
return []
|
||||
}
|
||||
const activeReviewFile = () => {
|
||||
const diffs = reviewDiffs()
|
||||
@@ -685,7 +700,7 @@ export default function Page() {
|
||||
return "main"
|
||||
})
|
||||
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
const setActiveMessage = (message: SessionMessageUser | undefined) => {
|
||||
messageMark = scrollMark
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AssistantMessage, Message, UserMessage } from "@/types"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import {
|
||||
normalizeSessionTab,
|
||||
@@ -9,26 +9,20 @@ import {
|
||||
} from "./session-domain"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
const user = (id: string): UserMessage => ({
|
||||
const user = (id: string): SessionMessageUser => ({
|
||||
id,
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
type: "user",
|
||||
text: id,
|
||||
time: { created: 0 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
})
|
||||
|
||||
const assistant: AssistantMessage = {
|
||||
const assistant: SessionMessageAssistant = {
|
||||
id: "msg_2",
|
||||
sessionID: "session",
|
||||
role: "assistant",
|
||||
type: "assistant",
|
||||
time: { created: 0 },
|
||||
parentID: "msg_1",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: "/workspace", root: "/workspace" },
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
@@ -45,11 +39,12 @@ describe("session controller invariants", () => {
|
||||
})
|
||||
|
||||
test("selects user history strictly before the revert boundary", () => {
|
||||
const messages: Message[] = [user("msg_z"), assistant, user("msg_b"), user("msg_c")]
|
||||
const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")]
|
||||
const users = selectSessionUserMessages(messages)
|
||||
|
||||
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
|
||||
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
|
||||
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
|
||||
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
|
||||
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([])
|
||||
expect(selectVisibleSessionUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Message, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useData } from "@/context/server"
|
||||
import { same } from "@/utils/same"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { createSessionTabs } from "./helpers"
|
||||
import {
|
||||
normalizeSessionTab,
|
||||
@@ -14,8 +13,8 @@ import {
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
const emptyUserMessages: SessionMessageUser[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionController(input: {
|
||||
@@ -40,12 +39,10 @@ export function createSessionController(input: {
|
||||
const id = sessionID()
|
||||
return id && data.session.status(id) === "running" ? { type: "busy" as const } : idle
|
||||
})
|
||||
const transcript = createMemo(() => {
|
||||
const messages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined
|
||||
return id ? data.session.message.list(id) : emptyMessages
|
||||
})
|
||||
const messages = createMemo(() => transcript()?.messages ?? emptyMessages)
|
||||
const parts = (messageID: string) => transcript()?.parts.get(messageID) ?? []
|
||||
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const visibleUserMessages = createMemo(
|
||||
@@ -84,7 +81,6 @@ export function createSessionController(input: {
|
||||
},
|
||||
history: {
|
||||
messages,
|
||||
parts,
|
||||
userMessages,
|
||||
visibleUserMessages,
|
||||
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
|
||||
export function normalizeSessionTab(tab: string, normalizeFileTab: (tab: string) => string) {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
@@ -9,12 +9,11 @@ export function normalizeSessionTabs(tabs: string[], normalize: (tab: string) =>
|
||||
return [...new Set(tabs.map(normalize))]
|
||||
}
|
||||
|
||||
export function selectSessionUserMessages(messages: Message[]) {
|
||||
return messages.filter((message): message is UserMessage => message.role === "user")
|
||||
export function selectSessionUserMessages(messages: SessionMessageInfo[]) {
|
||||
return messages.filter((message): message is SessionMessageUser => message.type === "user")
|
||||
}
|
||||
|
||||
export function selectVisibleSessionUserMessages(messages: UserMessage[], revertMessageID?: string) {
|
||||
export function selectVisibleSessionUserMessages(messages: SessionMessageUser[], revertMessageID?: string) {
|
||||
if (!revertMessageID) return messages
|
||||
const boundary = messages.findIndex((message) => message.id === revertMessageID)
|
||||
return boundary < 0 ? messages : messages.slice(0, boundary)
|
||||
return messages.filter((message) => message.id < revertMessageID)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { UserMessage } from "@/types"
|
||||
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||
|
||||
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
||||
({
|
||||
id: "msg",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: input?.agent ?? "build",
|
||||
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
}) as UserMessage
|
||||
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
|
||||
sessionID: "session",
|
||||
agent: input?.agent ?? "build",
|
||||
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
})
|
||||
|
||||
describe("syncSessionModel", () => {
|
||||
test("restores the last message through session state", () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { UserMessage } from "@/types"
|
||||
|
||||
type Local = {
|
||||
session: {
|
||||
reset(): void
|
||||
restore(msg: UserMessage): void
|
||||
restore(msg: {
|
||||
sessionID: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
}): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +31,10 @@ export const resetSessionModel = (local: Local) => {
|
||||
local.session.reset()
|
||||
}
|
||||
|
||||
export const syncSessionModel = (local: Local, msg: UserMessage) => {
|
||||
export const syncSessionModel = (
|
||||
local: Local,
|
||||
msg: { sessionID: string; agent: string; model: { providerID: string; modelID: string; variant?: string } },
|
||||
) => {
|
||||
local.session.restore(msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { visibleTimelineMessages } from "./controller-projection"
|
||||
|
||||
const messages = [
|
||||
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 2 },
|
||||
},
|
||||
{ id: "msg_3", type: "user", text: "queued", time: { created: 3 } },
|
||||
{ id: "msg_4", type: "user", text: "reverted", time: { created: 4 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
describe("visibleTimelineMessages", () => {
|
||||
test("hides queued inputs until delivery", () => {
|
||||
const pending = [
|
||||
{
|
||||
id: "msg_3",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 3,
|
||||
type: "user",
|
||||
delivery: "queue",
|
||||
payload: { text: "queued" },
|
||||
},
|
||||
] satisfies SessionInboxInfo[]
|
||||
|
||||
expect(visibleTimelineMessages(messages, pending).map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_4"])
|
||||
})
|
||||
|
||||
test("hides the staged revert boundary and later messages", () => {
|
||||
expect(visibleTimelineMessages(messages, [], "msg_4").map((message) => message.id)).toEqual([
|
||||
"msg_1",
|
||||
"msg_2",
|
||||
"msg_3",
|
||||
])
|
||||
expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,17 @@
|
||||
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export function visibleTimelineMessages(
|
||||
messages: SessionMessageInfo[],
|
||||
pending: SessionInboxInfo[],
|
||||
revertMessageID?: string,
|
||||
) {
|
||||
const queued = new Set(
|
||||
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
|
||||
)
|
||||
if (queued.size === 0 && !revertMessageID) return messages
|
||||
return messages.filter((message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID))
|
||||
}
|
||||
|
||||
export function timelineChildTitle(input: {
|
||||
parentID?: string
|
||||
taskDescription?: string
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Message, Part, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -17,17 +17,22 @@ import { sessionHref } from "@/utils/session-route"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { useServer } from "@/context/server"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const taskDescription = (part: Part, sessionID: string): string | undefined => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return undefined
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
if (metadata?.sessionId !== sessionID) return undefined
|
||||
const value = part.state.input?.description
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
|
||||
if (message.type !== "assistant") return
|
||||
const tool = message.content.findLast((item) => {
|
||||
if (item.type !== "tool" || (item.name !== "task" && item.name !== "subagent")) return false
|
||||
const metadata =
|
||||
item.state.status === "running" || item.state.status === "completed" ? item.state.metadata : undefined
|
||||
return metadata?.sessionId === sessionID || metadata?.sessionID === sessionID
|
||||
})
|
||||
if (tool?.type !== "tool") return
|
||||
const input = typeof tool.state.input === "string" ? undefined : tool.state.input
|
||||
const value = input?.description
|
||||
if (typeof value === "string" && value) return value
|
||||
return undefined
|
||||
}
|
||||
@@ -35,13 +40,10 @@ const taskDescription = (part: Part, sessionID: string): string | undefined => {
|
||||
export type TimelineSessionSource = {
|
||||
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
|
||||
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
|
||||
history: Pick<SessionController["history"], "messages" | "parts">
|
||||
history: Pick<SessionController["history"], "messages">
|
||||
}
|
||||
|
||||
export function createTimelineController(input: {
|
||||
session: TimelineSessionSource
|
||||
userMessages: Accessor<UserMessage[]>
|
||||
}) {
|
||||
export function createTimelineController(input: { session: TimelineSessionSource }) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -54,36 +56,28 @@ export function createTimelineController(input: {
|
||||
const platform = usePlatform()
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
if (!id) return []
|
||||
const visible = new Set(input.userMessages().map((message) => message.id))
|
||||
const boundary = input.session.history
|
||||
.messages()
|
||||
.find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||
const projected = data.session.message.list(id)
|
||||
if (!boundary) return projected
|
||||
const index = projected.findIndex((message) => message.id === boundary)
|
||||
return index < 0 ? projected : projected.slice(0, index)
|
||||
return visibleTimelineMessages(
|
||||
input.session.history.messages(),
|
||||
id ? data.session.pending.list(id) : [],
|
||||
input.session.data.info()?.revert?.messageID,
|
||||
)
|
||||
})
|
||||
const titleValue = createMemo(() => input.session.data.info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
|
||||
const shareUrl = (): string | undefined => undefined
|
||||
const shareEnabled = () => false
|
||||
const parentTranscript = createMemo(() => {
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = input.session.data.parentID()
|
||||
return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined
|
||||
return id ? data.session.message.list(id) : emptyMessages
|
||||
})
|
||||
const parentMessages = createMemo(() => parentTranscript()?.messages ?? emptyMessages)
|
||||
const parentTitle = createMemo(
|
||||
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const parts = input.session.history.parts
|
||||
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
if (!id) return undefined
|
||||
return parentMessages()
|
||||
.flatMap((message) => parentTranscript()?.parts.get(message.id) ?? [])
|
||||
.map((item) => taskDescription(item, id))
|
||||
.map((message) => taskDescription(message, id))
|
||||
.findLast((value): value is string => !!value)
|
||||
})
|
||||
const childTitle = createMemo(() => {
|
||||
@@ -96,10 +90,7 @@ export function createTimelineController(input: {
|
||||
})
|
||||
const showHeader = createMemo(() => !!input.session.identity.sessionID())
|
||||
const projection = createTimelineProjection({
|
||||
messages: input.session.history.messages,
|
||||
userMessages: input.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts,
|
||||
status: input.session.data.status,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
})
|
||||
@@ -238,8 +229,6 @@ export function createTimelineController(input: {
|
||||
parentTitle,
|
||||
childTitle,
|
||||
showHeader,
|
||||
parts,
|
||||
part,
|
||||
projection,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import {
|
||||
ContextToolGroup,
|
||||
Message,
|
||||
Part as MessagePart,
|
||||
partDefaultOpen,
|
||||
type UserActions,
|
||||
} from "@opencode-ai/session-ui/message-part"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
presentAssistantMessage,
|
||||
presentAssistantContent,
|
||||
presentUserMessage,
|
||||
presentUserParts,
|
||||
} from "@/utils/session-message"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
|
||||
export function CurrentUserMessage(props: {
|
||||
sessionID: string
|
||||
message: SessionMessageUser
|
||||
agent: string
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
actions?: UserActions
|
||||
useV2Actions?: boolean
|
||||
comments?: { path: string; comment: string; selection?: { startLine: number; endLine: number } }[]
|
||||
}) {
|
||||
const message = createMemo(() => presentUserMessage(props.sessionID, props.message, props.agent, props.model))
|
||||
const parts = createMemo(() => presentUserParts(props.sessionID, props.message))
|
||||
return (
|
||||
<Message
|
||||
message={message()}
|
||||
parts={parts()}
|
||||
actions={props.actions}
|
||||
useV2Actions={props.useV2Actions}
|
||||
comments={props.comments}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CurrentAssistantContent(props: {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
message: SessionMessageAssistant
|
||||
content: SessionMessageAssistant["content"][number]
|
||||
contentID: string
|
||||
showAssistantCopyPartID?: string | null
|
||||
turnDurationMs?: number
|
||||
useV2Actions?: boolean
|
||||
defaultOpen?: boolean
|
||||
toolOpen?: boolean
|
||||
onToolOpenChange?: (open: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
const message = createMemo(() => presentAssistantMessage(props.sessionID, props.parentID, props.message))
|
||||
const part = createMemo(() =>
|
||||
presentAssistantContent(props.sessionID, props.message, props.contentID, props.content),
|
||||
)
|
||||
return (
|
||||
<Show when={part()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part()}
|
||||
message={message()}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
useV2Actions={props.useV2Actions}
|
||||
defaultOpen={props.defaultOpen}
|
||||
toolOpen={props.toolOpen}
|
||||
onToolOpenChange={props.onToolOpenChange}
|
||||
deferToolContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function CurrentContextToolGroup(props: {
|
||||
sessionID: string
|
||||
tools: { message: SessionMessageAssistant; content: SessionMessageAssistantTool; contentID: string }[]
|
||||
open: boolean
|
||||
busy: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
}) {
|
||||
const parts = createMemo(() =>
|
||||
props.tools.flatMap(({ message, content, contentID }): ToolPart[] => {
|
||||
const part = presentAssistantContent(props.sessionID, message, contentID, content)
|
||||
return part?.type === "tool" ? [part] : []
|
||||
}),
|
||||
)
|
||||
return (
|
||||
<ContextToolGroup
|
||||
parts={parts()}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
busy={props.busy}
|
||||
onSizeChange={props.onSizeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function currentPartDefaultOpen(
|
||||
sessionID: string,
|
||||
message: SessionMessageAssistant,
|
||||
content: SessionMessageAssistant["content"][number],
|
||||
contentID: string,
|
||||
shellExpanded: boolean,
|
||||
editExpanded: boolean,
|
||||
) {
|
||||
return partDefaultOpen(
|
||||
presentAssistantContent(sessionID, message, contentID, content),
|
||||
shellExpanded,
|
||||
editExpanded,
|
||||
)
|
||||
}
|
||||
@@ -11,20 +11,10 @@ import {
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import {
|
||||
ContextToolGroup,
|
||||
Message,
|
||||
MessageDivider,
|
||||
Part as MessagePart,
|
||||
partDefaultOpen,
|
||||
type UserActions,
|
||||
} from "@opencode-ai/session-ui/message-part"
|
||||
import { MessageDivider, SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
@@ -34,14 +24,11 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { Project } from "@/types"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -49,17 +36,22 @@ import { useData } from "@/context/server"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { MessageComment, Timeline, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/utils/workspace"
|
||||
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
CurrentAssistantContent,
|
||||
CurrentContextToolGroup,
|
||||
CurrentUserMessage,
|
||||
currentPartDefaultOpen,
|
||||
} from "./current-message"
|
||||
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
const emptyAssistantMessages: SessionMessageAssistant[] = []
|
||||
|
||||
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
|
||||
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
||||
@@ -111,89 +103,6 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const maxFiles = 10
|
||||
const [state, setState] = createStore({
|
||||
showAll: false,
|
||||
expanded: [] as string[],
|
||||
})
|
||||
const showAll = () => state.showAll
|
||||
const expanded = () => state.expanded
|
||||
const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles))
|
||||
const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles)))
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="session-turn-diffs"
|
||||
data-component="session-turn-diffs-group"
|
||||
data-show-all={showAll() || undefined}
|
||||
>
|
||||
<div data-slot="session-turn-diffs-header">
|
||||
<span data-slot="session-turn-diffs-label">
|
||||
{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}
|
||||
</span>
|
||||
<DiffChanges changes={props.diffs} />
|
||||
<Show when={overflow() > 0}>
|
||||
<span data-slot="session-turn-diffs-toggle" onClick={() => setState("showAll", !showAll())}>
|
||||
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||
</span>
|
||||
</Show>
|
||||
{props.action}
|
||||
</div>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "44px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={visible()}>
|
||||
{(diff) => {
|
||||
const opened = createMemo(() => expanded().includes(diff.file))
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={opened()}>
|
||||
<TimelineDiffView diff={diff} />
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
<Show when={!showAll() && overflow() > 0}>
|
||||
<div data-slot="session-turn-diffs-more" onClick={() => setState("showAll", true)}>
|
||||
{language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
eligible: boolean
|
||||
@@ -353,17 +262,6 @@ function SessionSummaryPanel(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
const fileComponent = useFileComponent()
|
||||
const view = normalize(props.diff)
|
||||
|
||||
return (
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
session: TimelineSessionSource
|
||||
actions?: UserActions
|
||||
@@ -380,7 +278,7 @@ type MessageTimelineProps = {
|
||||
shouldAnchorBottom: boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
userMessages: SessionMessageUser[]
|
||||
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
|
||||
onReview: () => void
|
||||
workspaceMoveEligible: boolean
|
||||
@@ -392,7 +290,7 @@ type MessageTimelineProps = {
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
|
||||
const controller = createTimelineController({ session: props.session })
|
||||
return (
|
||||
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
||||
)
|
||||
@@ -425,8 +323,6 @@ function MessageTimelineView(
|
||||
const parentID = props.data.parentID
|
||||
const parentTitle = props.data.parentTitle
|
||||
const childTitle = props.data.childTitle
|
||||
const getMsgParts = props.data.parts
|
||||
const getMsgPart = props.data.part
|
||||
const projection = props.data.projection
|
||||
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
|
||||
const project = createMemo(() => {
|
||||
@@ -845,7 +741,7 @@ function MessageTimelineView(
|
||||
|
||||
const turnDurationMs = (userMessageID: string) => {
|
||||
const message = messageByID().get(userMessageID)
|
||||
if (!message || message.role !== "user") return
|
||||
if (message?.type !== "user") return
|
||||
const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce<number | undefined>(
|
||||
(max, item) => {
|
||||
const completed = item.time.completed
|
||||
@@ -868,23 +764,27 @@ function MessageTimelineView(
|
||||
const message = messages[i]
|
||||
if (!message) continue
|
||||
|
||||
const parts = getMsgParts(message.id)
|
||||
for (let j = parts.length - 1; j >= 0; j--) {
|
||||
const part = parts[j]
|
||||
if (!part || part.type !== "text" || !part.text?.trim()) continue
|
||||
return part.id
|
||||
const contents = Timeline.contentEntries(message)
|
||||
for (let j = contents.length - 1; j >= 0; j--) {
|
||||
const entry = contents[j]
|
||||
if (entry?.content.type !== "text" || !entry.content.text.trim()) continue
|
||||
return entry.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
|
||||
if (row().group.type === "context") {
|
||||
const parts = createMemo(() => {
|
||||
const tools = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "context") return emptyTools
|
||||
return group.refs
|
||||
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
||||
.filter((part): part is ToolPart => part?.type === "tool")
|
||||
if (group.type !== "context") return []
|
||||
return group.refs.flatMap((ref) => {
|
||||
const message = messageByID().get(ref.messageID)
|
||||
const content = Timeline.resolveContent(message, ref.partID)
|
||||
return message?.type === "assistant" && content?.type === "tool"
|
||||
? [{ message, content, contentID: ref.partID }]
|
||||
: []
|
||||
})
|
||||
})
|
||||
const contextOpenKey = () => `context:${row().group.key}`
|
||||
const open = createMemo(() => {
|
||||
@@ -892,8 +792,9 @@ function MessageTimelineView(
|
||||
})
|
||||
|
||||
return (
|
||||
<ContextToolGroup
|
||||
parts={parts()}
|
||||
<CurrentContextToolGroup
|
||||
sessionID={sessionID()!}
|
||||
tools={tools()}
|
||||
open={open()}
|
||||
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
|
||||
busy={
|
||||
@@ -909,33 +810,52 @@ function MessageTimelineView(
|
||||
if (group.type !== "part") return
|
||||
return messageByID().get(group.ref.messageID)
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const contentID = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "part") return
|
||||
return getMsgPart(group.ref.messageID, group.ref.partID)
|
||||
return group.ref.partID
|
||||
})
|
||||
const content = createMemo(() => {
|
||||
const current = message()
|
||||
const id = contentID()
|
||||
if (current?.type !== "assistant" || !id) return
|
||||
return Timeline.resolveContent(current, id)
|
||||
})
|
||||
const defaultOpen = createMemo(() => {
|
||||
const item = part()
|
||||
const group = row().group
|
||||
const current = message()
|
||||
if (group.type !== "part" || current?.type !== "assistant") return
|
||||
const item = content()
|
||||
if (!item) return
|
||||
return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
|
||||
return currentPartDefaultOpen(
|
||||
sessionID()!,
|
||||
current,
|
||||
item,
|
||||
group.ref.partID,
|
||||
props.data.shellToolPartsExpanded(),
|
||||
props.data.editToolPartsExpanded(),
|
||||
)
|
||||
})
|
||||
const id = contentID()
|
||||
if (!id) return
|
||||
|
||||
return (
|
||||
<Show when={message()}>
|
||||
<Show when={message()?.type === "assistant" ? (message() as SessionMessageAssistant) : undefined}>
|
||||
{(message) => (
|
||||
<Show when={part()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part()}
|
||||
<Show when={content()}>
|
||||
{(content) => (
|
||||
<CurrentAssistantContent
|
||||
sessionID={sessionID()!}
|
||||
parentID={row().userMessageID}
|
||||
message={message()}
|
||||
content={content()}
|
||||
contentID={id}
|
||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||
useV2Actions
|
||||
defaultOpen={defaultOpen()}
|
||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||
deferToolContent
|
||||
virtualizeDiff={false}
|
||||
toolOpen={toolOpen[row().group.key] ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => setToolOpen(row().group.key, open)}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
@@ -978,20 +898,24 @@ function MessageTimelineView(
|
||||
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
|
||||
const message = createMemo(() => {
|
||||
const m = messageByID().get(userMessageRow().userMessageID)
|
||||
if (m?.role === "user") return m
|
||||
if (m?.type === "user") return m
|
||||
})
|
||||
const messageComments = createMemo(() => {
|
||||
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
|
||||
const current = message()
|
||||
return current ? MessageComment.fromMessage(current) : []
|
||||
})
|
||||
const context = createMemo(() => projection.userContextByID().get(userMessageRow().userMessageID))
|
||||
return (
|
||||
<TimelineRowFrame row={userMessageRow()}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message
|
||||
<CurrentUserMessage
|
||||
sessionID={sessionID()!}
|
||||
message={message()}
|
||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||
agent={context()?.agent ?? ""}
|
||||
model={context()?.model ?? { id: "", providerID: "" }}
|
||||
actions={props.actions}
|
||||
useV2Actions
|
||||
comments={messageComments()}
|
||||
@@ -1003,6 +927,29 @@ function MessageTimelineView(
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "Shell": {
|
||||
const shellRow = row as Accessor<TimelineRowByTag<"Shell">>
|
||||
const message = createMemo(() => {
|
||||
const current = sessionMessageByID().get(shellRow().messageID)
|
||||
return current?.type === "shell" ? current : undefined
|
||||
})
|
||||
return (
|
||||
<TimelineRowFrame row={shellRow()}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<SessionShellMessage
|
||||
message={message()}
|
||||
defaultOpen={props.data.shellToolPartsExpanded()}
|
||||
open={toolOpen[message().id]}
|
||||
onOpenChange={(open) => setToolOpen(message().id, open)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "Notice": {
|
||||
const noticeRow = row as Accessor<TimelineRowByTag<"Notice">>
|
||||
const content = createMemo(() => {
|
||||
@@ -1031,11 +978,7 @@ function MessageTimelineView(
|
||||
<TimelineRowFrame row={turnDividerRow()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
label={language.t(
|
||||
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
|
||||
)}
|
||||
/>
|
||||
<MessageDivider label={language.t("ui.message.interrupted")} />
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
@@ -1071,43 +1014,17 @@ function MessageTimelineView(
|
||||
}
|
||||
case "Retry": {
|
||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
const status = createMemo(() => {
|
||||
const retry = (assistantMessagesByParent().get(retryRow().userMessageID) ?? emptyAssistantMessages).at(
|
||||
-1,
|
||||
)?.retry
|
||||
if (!retry) return sessionStatus()
|
||||
return { type: "retry" as const, attempt: retry.attempt, message: retry.error.message, next: retry.at }
|
||||
})
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
const canMove = () =>
|
||||
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
|
||||
!workspaceSession() &&
|
||||
props.workspaceMoveEligible &&
|
||||
project()?.vcs === "git" &&
|
||||
sessionStatus().type === "idle"
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineDiffSummaryRow
|
||||
diffs={diffSummaryRow().diffs}
|
||||
action={
|
||||
<Show when={canMove() && project()}>
|
||||
{(project) => (
|
||||
<WorkspaceMoveAction
|
||||
variant="inline"
|
||||
eligible={props.workspaceMoveEligible}
|
||||
sessionID={sessionID()!}
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
dismissed={workspaceSuggestionDismissed()}
|
||||
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<SessionRetry status={status()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
@@ -1140,10 +1057,10 @@ function MessageTimelineView(
|
||||
const tool = () => {
|
||||
const value = row()
|
||||
if (value._tag !== "AssistantPart" || value.group.type !== "part") return
|
||||
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
|
||||
if (part?.type === "tool") return part
|
||||
const content = Timeline.resolveContent(messageByID().get(value.group.ref.messageID), value.group.ref.partID)
|
||||
if (content?.type === "tool") return content
|
||||
}
|
||||
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "")
|
||||
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.name ?? "")
|
||||
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
|
||||
let contentMeasureFrame: number | undefined
|
||||
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AssistantMessage, Message, UserMessage } from "@/types"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||
|
||||
const user = (id: string) => ({ id, role: "user" }) as UserMessage
|
||||
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
|
||||
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
|
||||
const assistant = (id: string): SessionMessageAssistant => ({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 1 },
|
||||
})
|
||||
|
||||
describe("timeline model", () => {
|
||||
test("selects users and applies the revert boundary", () => {
|
||||
const messages: Message[] = [user("msg_z"), assistant("msg_a"), user("msg_b"), user("msg_c")]
|
||||
const messages: SessionMessageInfo[] = [user("msg_a"), assistant("msg_ab"), user("msg_b"), user("msg_c")]
|
||||
const users = selectUserMessages(messages)
|
||||
|
||||
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
|
||||
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
|
||||
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
|
||||
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
|
||||
expect(selectVisibleUserMessages(users.slice(2), "msg_b")).toEqual([])
|
||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
test("waits for an assistant-only load to hydrate its user root", () => {
|
||||
expect(isTimelineReady([assistant("msg_2")], true)).toBe(false)
|
||||
expect(isTimelineReady([user("msg_1"), assistant("msg_2")], true)).toBe(true)
|
||||
const currentAssistant = {
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 2 },
|
||||
} satisfies SessionMessageInfo
|
||||
const currentUser = { id: "msg_1", type: "user", text: "hello", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
expect(isTimelineReady([currentAssistant], true)).toBe(false)
|
||||
expect(isTimelineReady([currentUser, currentAssistant], true)).toBe(true)
|
||||
expect(isTimelineReady([], false)).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message } from "@/types"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo, createResource, type Accessor } from "solid-js"
|
||||
import { useData } from "@/context/server"
|
||||
import type { SessionController } from "../session-controller"
|
||||
@@ -46,8 +46,11 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
|
||||
}
|
||||
}
|
||||
|
||||
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
|
||||
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
|
||||
export function isTimelineReady(messages: SessionMessageInfo[] | undefined, loading: boolean) {
|
||||
return (
|
||||
messages !== undefined &&
|
||||
(messages.some((message) => message.type === "user" || message.type === "shell") || !loading)
|
||||
)
|
||||
}
|
||||
|
||||
export async function loadOlderTimeline(input: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, UserMessage } from "@/types"
|
||||
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { Timeline, TimelineRow } from "./rows"
|
||||
@@ -7,39 +6,76 @@ import { Timeline, TimelineRow } from "./rows"
|
||||
export { reuseTimelineRows } from "./row-reconciliation"
|
||||
|
||||
export function createTimelineProjection(input: {
|
||||
messages: Accessor<Message[]>
|
||||
userMessages: Accessor<UserMessage[]>
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
parts: (messageID: string) => Part[]
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
}) {
|
||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
)
|
||||
const userContextByID = createMemo(() => {
|
||||
const result = new Map<string, { agent: string; model: ModelRef }>()
|
||||
let agent = ""
|
||||
let model: ModelRef = { id: "", providerID: "" }
|
||||
let userID: string | undefined
|
||||
input.sessionMessages().forEach((message) => {
|
||||
if (message.type === "agent-switched") agent = message.agent
|
||||
if (message.type === "model-switched") model = message.model
|
||||
if (message.type === "user") {
|
||||
userID = message.id
|
||||
const metadata = message.metadata
|
||||
const localAgent = typeof metadata?.agent === "string" ? metadata.agent : agent
|
||||
const localModel = metadata?.model
|
||||
const localModelID =
|
||||
localModel && typeof localModel === "object" && !Array.isArray(localModel)
|
||||
? typeof localModel.id === "string"
|
||||
? localModel.id
|
||||
: typeof localModel.modelID === "string"
|
||||
? localModel.modelID
|
||||
: undefined
|
||||
: undefined
|
||||
result.set(message.id, {
|
||||
agent: localAgent,
|
||||
model:
|
||||
localModel &&
|
||||
typeof localModel === "object" &&
|
||||
!Array.isArray(localModel) &&
|
||||
localModelID &&
|
||||
typeof localModel.providerID === "string"
|
||||
? {
|
||||
id: localModelID,
|
||||
providerID: localModel.providerID,
|
||||
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
|
||||
}
|
||||
: model,
|
||||
})
|
||||
}
|
||||
if (message.type === "shell") userID = undefined
|
||||
if (message.type !== "assistant") return
|
||||
agent = message.agent
|
||||
model = message.model
|
||||
if (userID) result.set(userID, { agent, model })
|
||||
})
|
||||
return result
|
||||
})
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
const result = new Map<string, AssistantMessage[]>()
|
||||
input.messages().forEach((message) => {
|
||||
if (message.role !== "assistant") return
|
||||
const messages = result.get(message.parentID)
|
||||
const result = new Map<string, Extract<SessionMessageInfo, { type: "assistant" }>[]>()
|
||||
let userID: string | undefined
|
||||
input.sessionMessages().forEach((message) => {
|
||||
if (message.type === "user") userID = message.id
|
||||
if (message.type === "shell") userID = undefined
|
||||
if (message.type !== "assistant" || !userID) return
|
||||
const messages = result.get(userID)
|
||||
if (messages) {
|
||||
messages.push(message)
|
||||
return
|
||||
}
|
||||
result.set(message.parentID, [message])
|
||||
result.set(userID, [message])
|
||||
})
|
||||
return result
|
||||
})
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||
input.parts,
|
||||
input.showReasoningSummaries(),
|
||||
input.status().type,
|
||||
input.userMessages(),
|
||||
),
|
||||
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status().type),
|
||||
)
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
@@ -73,11 +109,12 @@ export function createTimelineProjection(input: {
|
||||
activeMessageID,
|
||||
assistantMessagesByParent,
|
||||
lastAssistantGroupKey,
|
||||
messageByID,
|
||||
messageByID: sessionMessageByID,
|
||||
messageRowIndex,
|
||||
messageLastRowIndex,
|
||||
rowByKey,
|
||||
rows,
|
||||
sessionMessageByID,
|
||||
userContextByID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
|
||||
mock.module("@opencode-ai/session-ui/message-part", () => ({
|
||||
renderable: () => true,
|
||||
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
|
||||
refs.map((ref) => ({
|
||||
type: "part" as const,
|
||||
key: ref.part.id,
|
||||
ref: { messageID: ref.messageID, partID: ref.part.id },
|
||||
})),
|
||||
}))
|
||||
|
||||
const { Timeline, TimelineRow } = await import("./rows")
|
||||
|
||||
@@ -36,25 +25,15 @@ describe("current session timeline rows", () => {
|
||||
time: { created: 5 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"busy",
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(source, true, "busy")
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_3")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_1",
|
||||
"assistant-part:msg_1:msg_2:text:0",
|
||||
"assistant-part:msg_1:part:msg_2:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"assistant-part:msg_3:msg_4:reasoning:0",
|
||||
"assistant-part:msg_3:part:msg_4:msg_4:reasoning:0",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -71,22 +50,37 @@ describe("current session timeline rows", () => {
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"idle",
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(source, true, "idle")
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_shell")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual(["shell:msg_shell"])
|
||||
})
|
||||
|
||||
test("keeps assistant content when no user root is available", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_notice",
|
||||
type: "synthetic",
|
||||
text: "done",
|
||||
description: "Background work completed",
|
||||
time: { created: 1 },
|
||||
},
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "result" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(source, true, "idle")
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_assistant")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_shell",
|
||||
"assistant-part:msg_shell:msg_shell:tool",
|
||||
"notice:msg_notice",
|
||||
"assistant-part:msg_assistant:part:msg_assistant:msg_assistant:text:0",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -142,97 +136,29 @@ describe("current session timeline rows", () => {
|
||||
time: { created: 11 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"idle",
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(source, true, "idle")
|
||||
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user",
|
||||
"notice:msg_agent",
|
||||
"assistant-part:msg_user:msg_assistant_1:text:0",
|
||||
"assistant-part:msg_user:part:msg_assistant_1:msg_assistant_1:text:0",
|
||||
"notice:msg_background",
|
||||
"notice:msg_model",
|
||||
"assistant-part:msg_user:msg_assistant_2:text:0",
|
||||
"assistant-part:msg_user:part:msg_assistant_2:msg_assistant_2:text:0",
|
||||
"notice:msg_restart",
|
||||
"notice:msg_skill",
|
||||
"notice:msg_compaction",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a projected parent missing from the source page before newer turns", () => {
|
||||
const source = [
|
||||
{ id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant_1",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "first answer" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{ id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } },
|
||||
{
|
||||
id: "msg_assistant_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "second answer" }],
|
||||
time: { created: 5, completed: 6 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source.slice(1),
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"idle",
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user_1",
|
||||
"assistant-part:msg_user_1:msg_assistant_1:text:0",
|
||||
"turn-gap:msg_user_2",
|
||||
"user-message:msg_user_2",
|
||||
"assistant-part:msg_user_2:msg_assistant_2:text:0",
|
||||
])
|
||||
})
|
||||
|
||||
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
|
||||
const source = [
|
||||
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
|
||||
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const optimistic = {
|
||||
id: "msg_a",
|
||||
sessionID: "ses_1",
|
||||
role: "user" as const,
|
||||
time: { created: 2 },
|
||||
agent: "build",
|
||||
model: { modelID: "model", providerID: "provider" },
|
||||
}
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) =>
|
||||
messageID === optimistic.id ? optimistic : normalized.messages.find((message) => message.id === messageID),
|
||||
() => [],
|
||||
true,
|
||||
"busy",
|
||||
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(source, true, "busy")
|
||||
|
||||
expect(result.activeMessageID).toBe(optimistic.id)
|
||||
expect(result.activeMessageID).toBe("msg_a")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_z",
|
||||
"turn-gap:msg_a",
|
||||
@@ -241,6 +167,25 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("renders retry state from the current assistant message", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "retry", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
retry: { attempt: 2, at: 10, error: { type: "ProviderError", message: "rate limited" } },
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(source, true, "busy")
|
||||
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
|
||||
})
|
||||
|
||||
test("removes a failed assistant error when the turn continues streaming", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
|
||||
@@ -262,17 +207,7 @@ describe("current session timeline rows", () => {
|
||||
time: { created: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"busy",
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(source, true, "busy")
|
||||
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
|
||||
})
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Part, UserMessage } from "@/types"
|
||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
import { compareMessages } from "@/utils/session-message"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/utils/comment-note"
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionMessageShell,
|
||||
SessionMessageUser,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
export { TimelineRow } from "./timeline-row"
|
||||
|
||||
export type TimelineRowMap = {
|
||||
TurnGap: { userMessageID: string }
|
||||
UserMessage: {
|
||||
userMessageID: string
|
||||
}
|
||||
Shell: { userMessageID: string; messageID: string }
|
||||
Notice: { userMessageID: string; messageID: string }
|
||||
TurnDivider: {
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
}
|
||||
AssistantPart: {
|
||||
userMessageID: string
|
||||
@@ -25,22 +29,31 @@ export type TimelineRowMap = {
|
||||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
|
||||
type Assistant = SessionMessageAssistant
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Entry = { type: "assistant"; message: Assistant } | { type: "notice"; message: Notice }
|
||||
type Content = Assistant["content"][number]
|
||||
type ContentRef = { messageID: string; partID: string }
|
||||
|
||||
const contextTools = new Set(["read", "glob", "grep", "list"])
|
||||
|
||||
export namespace Timeline {
|
||||
export function constructSessionMessageRows(
|
||||
messages: SessionMessageInfo[],
|
||||
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
|
||||
getMessageParts: (messageID: string) => Part[],
|
||||
showReasoning: boolean,
|
||||
status: SessionStatus["type"],
|
||||
projectedUserMessages: UserMessage[],
|
||||
) {
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Entry = { type: "assistant"; message: AssistantMessage } | { type: "notice"; message: Notice }
|
||||
const turns: { user: UserMessage; entries: Entry[] }[] = []
|
||||
type Turn = {
|
||||
id: string
|
||||
time: { created: number }
|
||||
user?: SessionMessageUser
|
||||
shell?: SessionMessageShell
|
||||
entries: Entry[]
|
||||
}
|
||||
const turns: Turn[] = []
|
||||
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||
const leading: Notice[] = []
|
||||
let current: (typeof turns)[number] | undefined
|
||||
@@ -50,80 +63,71 @@ export namespace Timeline {
|
||||
if (!current) leading.push(message)
|
||||
return
|
||||
}
|
||||
const projected = getMessage(message.id)
|
||||
if (message.type === "shell" && projected?.role === "user") {
|
||||
const assistant = getMessage(`${message.id}:assistant`)
|
||||
const turn = {
|
||||
user: projected,
|
||||
entries: assistant?.role === "assistant" ? [{ type: "assistant" as const, message: assistant }] : [],
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
const turn: Turn = { id: message.id, time: message.time, shell: message, entries: [] }
|
||||
turns.push(turn)
|
||||
turnByUserID.set(projected.id, turn)
|
||||
current = turn
|
||||
return
|
||||
}
|
||||
if (projected?.role === "user") {
|
||||
if (turnByUserID.has(projected.id)) return
|
||||
const turn = { user: projected, entries: [] }
|
||||
if (message.type === "user") {
|
||||
if (turnByUserID.has(message.id)) return
|
||||
const turn: Turn = { id: message.id, time: message.time, user: message, entries: [] }
|
||||
turns.push(turn)
|
||||
turnByUserID.set(projected.id, turn)
|
||||
turnByUserID.set(message.id, turn)
|
||||
current = turn
|
||||
return
|
||||
}
|
||||
if (projected?.role !== "assistant") return
|
||||
const existing = current ?? turnByUserID.get(projected.parentID)
|
||||
if (existing) {
|
||||
existing.entries.push({ type: "assistant", message: projected })
|
||||
if (message.type !== "assistant") return
|
||||
const existing = current?.user ? current : undefined
|
||||
if (existing?.user) {
|
||||
existing.entries.push({ type: "assistant", message })
|
||||
current = existing
|
||||
return
|
||||
}
|
||||
const user = getMessage(projected.parentID)
|
||||
if (user?.role !== "user") return
|
||||
const turn = { user, entries: [{ type: "assistant" as const, message: projected }] }
|
||||
if (current && !current.user && !current.shell) {
|
||||
current.entries.push({ type: "assistant", message })
|
||||
return
|
||||
}
|
||||
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "assistant", message }] }
|
||||
turns.push(turn)
|
||||
turnByUserID.set(user.id, turn)
|
||||
current = turn
|
||||
})
|
||||
const notices = new Set(messages.filter(isNotice).map((message) => message.id))
|
||||
projectedUserMessages.forEach((user) => {
|
||||
if (notices.has(user.id)) return
|
||||
if (turnByUserID.has(user.id)) return
|
||||
const turn = { user, entries: [] }
|
||||
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
|
||||
if (index < 0) turns.push(turn)
|
||||
if (index >= 0) turns.splice(index, 0, turn)
|
||||
turnByUserID.set(user.id, turn)
|
||||
})
|
||||
const activeMessageID = turns.at(-1)?.user.id
|
||||
const activeMessageID = turns.at(-1)?.id
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: [
|
||||
...leading.map(
|
||||
(message) =>
|
||||
new TimelineRow.Notice({ userMessageID: turns[0]?.user.id ?? message.id, messageID: message.id }),
|
||||
(message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id }),
|
||||
),
|
||||
...turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
...turns.flatMap((turn, index) => {
|
||||
if (turn.shell)
|
||||
return [
|
||||
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
|
||||
new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id }),
|
||||
...turn.entries.flatMap((entry) =>
|
||||
entry.type === "notice"
|
||||
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
|
||||
: [],
|
||||
),
|
||||
]
|
||||
return constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.id,
|
||||
turn.entries,
|
||||
index,
|
||||
showReasoning,
|
||||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
),
|
||||
),
|
||||
turn.id === activeMessageID,
|
||||
)
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function constructMessageRows(
|
||||
userMessage: UserMessage,
|
||||
getMessageParts: (messageID: string) => Part[],
|
||||
entries: Array<
|
||||
| { type: "assistant"; message: AssistantMessage }
|
||||
| { type: "notice"; message: Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> }
|
||||
>,
|
||||
userMessage: SessionMessageUser | undefined,
|
||||
turnID: string,
|
||||
entries: Entry[],
|
||||
index: number,
|
||||
showReasoning: boolean,
|
||||
status: SessionStatus["type"],
|
||||
@@ -133,45 +137,38 @@ export namespace Timeline {
|
||||
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
|
||||
|
||||
const previousUserMessage = index > 0
|
||||
const userParts = getMessageParts(userMessage.id)
|
||||
const compaction =
|
||||
userParts.some((p) => p.type === "compaction") &&
|
||||
!entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
|
||||
const latestError = assistantMessages.at(-1)?.error
|
||||
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
|
||||
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
|
||||
const error = assistantMessages.at(-1)?.error
|
||||
const retry = assistantMessages.at(-1)?.retry
|
||||
const interrupted = error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt")
|
||||
|
||||
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
|
||||
getMessageParts(message.id)
|
||||
.filter((part) => renderable(part, showReasoning))
|
||||
.map((part) => ({ messageID: message.id, messageIndex, part })),
|
||||
contentEntries(message)
|
||||
.filter((entry) => renderable(entry.content, showReasoning))
|
||||
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
|
||||
)
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
|
||||
|
||||
rows.push(new TimelineRow.UserMessage({ userMessageID: userMessage.id }))
|
||||
|
||||
if (compaction) {
|
||||
rows.push(
|
||||
new TimelineRow.TurnDivider({
|
||||
userMessageID: userMessage.id,
|
||||
label: "compaction",
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
|
||||
|
||||
let assistantGroupIndex = 0
|
||||
const appendAssistants = (messages: AssistantMessage[]) => {
|
||||
const appendAssistants = (messages: Assistant[]) => {
|
||||
const ids = new Set(messages.map((message) => message.id))
|
||||
const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID))
|
||||
const interruptedAt = messages.findIndex((message) => message.error?.name === "MessageAbortedError")
|
||||
const interruptedAt = messages.findIndex(
|
||||
(message) =>
|
||||
message.error?.type.toLowerCase().includes("abort") ||
|
||||
message.error?.type.toLowerCase().includes("interrupt"),
|
||||
)
|
||||
const interruptedID = messages[interruptedAt]?.id
|
||||
const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID)
|
||||
const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs
|
||||
const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : []
|
||||
const appendGroups = (items: typeof refs) =>
|
||||
groupParts(items).forEach((group) => {
|
||||
groupContent(items).forEach((group) => {
|
||||
rows.push(
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID: userMessage.id,
|
||||
userMessageID: turnID,
|
||||
group,
|
||||
previousAssistantPart: assistantGroupIndex > 0,
|
||||
}),
|
||||
@@ -179,11 +176,10 @@ export namespace Timeline {
|
||||
assistantGroupIndex += 1
|
||||
})
|
||||
appendGroups(before)
|
||||
if (interruptedAt >= 0 && !compaction)
|
||||
rows.push(new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: "interrupted" }))
|
||||
if (interruptedAt >= 0 && !compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
|
||||
appendGroups(after)
|
||||
}
|
||||
let assistantSegment: AssistantMessage[] = []
|
||||
let assistantSegment: Assistant[] = []
|
||||
entries.forEach((entry) => {
|
||||
if (entry.type === "assistant") {
|
||||
assistantSegment.push(entry.message)
|
||||
@@ -191,44 +187,31 @@ export namespace Timeline {
|
||||
}
|
||||
appendAssistants(assistantSegment)
|
||||
assistantSegment = []
|
||||
rows.push(new TimelineRow.Notice({ userMessageID: userMessage.id, messageID: entry.message.id }))
|
||||
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
|
||||
})
|
||||
appendAssistants(assistantSegment)
|
||||
|
||||
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
|
||||
if (isActive && status === "busy" && !error && !retry && (showReasoning ? assistantPartRefs.length === 0 : true)) {
|
||||
const heading = assistantMessages
|
||||
.flatMap((message) => getMessageParts(message.id))
|
||||
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
|
||||
.flatMap((message) => message.content)
|
||||
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
|
||||
.find((value): value is string => !!value)
|
||||
|
||||
rows.push(
|
||||
new TimelineRow.Thinking({
|
||||
userMessageID: userMessage.id,
|
||||
userMessageID: turnID,
|
||||
reasoningHeading: heading,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
|
||||
if (isActive && retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID }))
|
||||
|
||||
const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs)
|
||||
if (diffs.length > 0 && (status === "idle" || !isActive)) {
|
||||
rows.push(
|
||||
new TimelineRow.DiffSummary({
|
||||
userMessageID: userMessage.id,
|
||||
diffs,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const data = error.data && "message" in error.data ? error.data.message : undefined
|
||||
if (error && !interrupted) {
|
||||
rows.push(
|
||||
new TimelineRow.Error({
|
||||
userMessageID: userMessage.id,
|
||||
text: unwrapErrorMessage(
|
||||
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
|
||||
),
|
||||
userMessageID: turnID,
|
||||
text: unwrapErrorMessage(error.message),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -236,6 +219,52 @@ export namespace Timeline {
|
||||
return rows
|
||||
}
|
||||
|
||||
export function resolveContent(message: SessionMessageInfo | undefined, partID: string) {
|
||||
if (message?.type !== "assistant") return
|
||||
return contentEntries(message).find((entry) => entry.id === partID)?.content
|
||||
}
|
||||
|
||||
export function contentEntries(message: Assistant) {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.content.map((content) => ({
|
||||
id: content.type === "tool" ? content.id : `${message.id}:${content.type}:${ordinals[content.type]++}`,
|
||||
content,
|
||||
}))
|
||||
}
|
||||
|
||||
function renderable(content: Content, showReasoning: boolean) {
|
||||
if (content.type === "text") return !!content.text.trim()
|
||||
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
|
||||
if (content.name === "todowrite") return false
|
||||
if (content.name === "question") return content.state.status !== "streaming" && content.state.status !== "running"
|
||||
return true
|
||||
}
|
||||
|
||||
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let context: ContentRef[] = []
|
||||
const flush = () => {
|
||||
const first = context[0]
|
||||
if (!first) return
|
||||
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
|
||||
context = []
|
||||
}
|
||||
items.forEach((item) => {
|
||||
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
|
||||
context.push({ messageID: item.messageID, partID: item.partID })
|
||||
return
|
||||
}
|
||||
flush()
|
||||
groups.push({
|
||||
type: "part",
|
||||
key: `part:${item.messageID}:${item.partID}`,
|
||||
ref: { messageID: item.messageID, partID: item.partID },
|
||||
})
|
||||
})
|
||||
flush()
|
||||
return groups
|
||||
}
|
||||
|
||||
function reasoningHeading(text: string) {
|
||||
const markdown = text.replace(/\r\n?/g, "\n")
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
|
||||
@@ -341,19 +370,16 @@ export namespace MessageComment {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromPart = (part: Part): MessageComment | undefined => {
|
||||
if (part.type !== "text" || !part.synthetic) return
|
||||
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
|
||||
if (!next) return
|
||||
return {
|
||||
path: next.path,
|
||||
comment: next.comment,
|
||||
selection: next.selection
|
||||
? {
|
||||
startLine: next.selection.startLine,
|
||||
endLine: next.selection.endLine,
|
||||
}
|
||||
export const fromMessage = (message: SessionMessageUser): MessageComment[] => {
|
||||
const presentation = readPromptPresentation(message.metadata)
|
||||
const parsed = presentation ? undefined : parseCommentNote(message.text)
|
||||
const comments = presentation?.comments ?? (parsed ? [parsed] : [])
|
||||
return comments.map((comment) => ({
|
||||
path: comment.path,
|
||||
comment: comment.comment,
|
||||
selection: comment.selection
|
||||
? { startLine: comment.selection.startLine, endLine: comment.selection.endLine }
|
||||
: undefined,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
|
||||
const diff = (file: string, additions: number) =>
|
||||
({
|
||||
file,
|
||||
patch: "",
|
||||
additions,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
}) satisfies FileDiffInfo
|
||||
|
||||
describe("uniqueSummaryDiffs", () => {
|
||||
test("drops entries without files and preserves unique input", () => {
|
||||
const alpha = diff("alpha.ts", 1)
|
||||
const beta = diff("beta.ts", 1)
|
||||
expect(uniqueSummaryDiffs(undefined)).toEqual([])
|
||||
expect(uniqueSummaryDiffs([])).toEqual([])
|
||||
|
||||
const result = uniqueSummaryDiffs([alpha, beta])
|
||||
expect(result).toEqual([alpha, beta])
|
||||
expect(result[0]).toBe(alpha)
|
||||
expect(result[1]).toBe(beta)
|
||||
})
|
||||
|
||||
test("keeps the last diff per file in display order", () => {
|
||||
const oldAlpha = diff("alpha.ts", 1)
|
||||
const oldBeta = diff("beta.ts", 1)
|
||||
const newAlpha = diff("alpha.ts", 2)
|
||||
const charlie = diff("charlie.ts", 1)
|
||||
const newBeta = diff("beta.ts", 2)
|
||||
|
||||
const result = uniqueSummaryDiffs([oldAlpha, oldBeta, newAlpha, charlie, newBeta])
|
||||
|
||||
expect(result).toEqual([newAlpha, charlie, newBeta])
|
||||
expect(result[0]).toBe(newAlpha)
|
||||
expect(result[1]).toBe(charlie)
|
||||
expect(result[2]).toBe(newBeta)
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SummaryDiff } from "./timeline-row"
|
||||
|
||||
export function uniqueSummaryDiffs(diffs: FileDiffInfo[] | undefined) {
|
||||
const files = new Set<string>()
|
||||
return (diffs ?? [])
|
||||
.reduceRight<SummaryDiff[]>((result, diff) => {
|
||||
if (!isSummaryDiff(diff)) return result
|
||||
const file = diff.file
|
||||
if (files.has(file)) return result
|
||||
files.add(file)
|
||||
result.push(diff)
|
||||
return result
|
||||
}, [])
|
||||
.reverse()
|
||||
}
|
||||
|
||||
function isSummaryDiff(diff: FileDiffInfo): diff is SummaryDiff {
|
||||
return typeof diff.file === "string"
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
|
||||
export type SummaryDiff = FileDiffInfo
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
userMessageID: string
|
||||
@@ -11,13 +8,16 @@ export namespace TimelineRow {
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class Shell extends Data.TaggedClass("Shell")<{
|
||||
userMessageID: string
|
||||
messageID: string
|
||||
}> {}
|
||||
export class Notice extends Data.TaggedClass("Notice")<{
|
||||
userMessageID: string
|
||||
messageID: string
|
||||
}> {}
|
||||
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
}> {}
|
||||
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
|
||||
userMessageID: string
|
||||
@@ -28,10 +28,6 @@ export namespace TimelineRow {
|
||||
userMessageID: string
|
||||
reasoningHeading?: string
|
||||
}> {}
|
||||
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
|
||||
userMessageID: string
|
||||
diffs: SummaryDiff[]
|
||||
}> {}
|
||||
export class Error extends Data.TaggedClass("Error")<{
|
||||
userMessageID: string
|
||||
text: string
|
||||
@@ -43,11 +39,11 @@ export namespace TimelineRow {
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
| UserMessage
|
||||
| Shell
|
||||
| Notice
|
||||
| TurnDivider
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
|
||||
@@ -57,16 +53,16 @@ export namespace TimelineRow {
|
||||
return `turn-gap:${row.userMessageID}`
|
||||
case "UserMessage":
|
||||
return `user-message:${row.userMessageID}`
|
||||
case "Shell":
|
||||
return `shell:${row.messageID}`
|
||||
case "Notice":
|
||||
return `notice:${row.messageID}`
|
||||
case "TurnDivider":
|
||||
return `turn-divider:${row.userMessageID}:${row.label}`
|
||||
return `turn-divider:${row.userMessageID}`
|
||||
case "AssistantPart":
|
||||
return `assistant-part:${row.userMessageID}:${row.group.key}`
|
||||
case "Thinking":
|
||||
return `thinking:${row.userMessageID}`
|
||||
case "DiffSummary":
|
||||
return `diff-summary:${row.userMessageID}`
|
||||
case "Error":
|
||||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
|
||||
@@ -52,13 +52,13 @@ export function useUsageExceededDialogs() {
|
||||
|
||||
onCleanup(
|
||||
sdk().event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== params.id) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
const { action } = evt.properties.status
|
||||
if (evt.data.sessionID !== params.id) return
|
||||
if (evt.data.status.type !== "retry") return
|
||||
const { action } = evt.data.status
|
||||
if (!action) return
|
||||
if (dialog.active) return
|
||||
|
||||
const keys = goUpsellKeys(evt.properties.status)
|
||||
const keys = goUpsellKeys(evt.data.status)
|
||||
if (!keys) return
|
||||
|
||||
const seen = goUpsellState[keys.lastSeenAt]
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import type { UserMessage } from "@/types"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { SessionController } from "./session-controller"
|
||||
|
||||
type SessionCommandSource = {
|
||||
@@ -31,7 +33,7 @@ export type SessionCommandContext = {
|
||||
move: () => Promise<void>
|
||||
}
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setActiveMessage: (message: SessionMessageUser | undefined) => void
|
||||
focusInput: () => void
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const prompt = usePrompt()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
@@ -59,6 +62,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const value = await load()
|
||||
owner.run(() => show(value))
|
||||
}
|
||||
const runCommand = async <T,>(input: {
|
||||
owner: ReturnType<SessionController["ownership"]["capture"]>
|
||||
prompt: T
|
||||
request: () => Promise<unknown>
|
||||
updatePrompt: (prompt: T) => void
|
||||
updateViewport: () => void
|
||||
}) => {
|
||||
await input.request()
|
||||
input.updatePrompt(input.prompt)
|
||||
input.owner.run(input.updateViewport)
|
||||
}
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const showAllFiles = () => {
|
||||
@@ -86,6 +100,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const navigateMessageByOffset = actions.navigateMessageByOffset
|
||||
const setActiveMessage = actions.setActiveMessage
|
||||
const focusInput = actions.focusInput
|
||||
|
||||
const sessionCommand = withCategory(language.t("command.category.session"))
|
||||
@@ -273,6 +288,75 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = serverSDK.api.session
|
||||
const promptSession = prompt.capture()
|
||||
const revert = actions.session.data.revertMessageID()
|
||||
const messages = actions.session.history.userMessages()
|
||||
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
|
||||
if (boundary < 0) return
|
||||
const message = messages[boundary - 1]
|
||||
if (!message) return
|
||||
|
||||
if (data.session.status(sessionID) === "running") await session.interrupt({ sessionID }).catch(() => {})
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => session.revert.stage({ sessionID, messageID: message.id }),
|
||||
updatePrompt: (target) => {
|
||||
target.set(extractPromptFromMessage(message, { directory: sdk().directory }))
|
||||
target.context.replaceComments(
|
||||
extractPromptComments(message).map((comment) => ({
|
||||
type: "file",
|
||||
path: comment.path,
|
||||
selection: comment.selection,
|
||||
comment: comment.comment,
|
||||
preview: comment.preview,
|
||||
commentOrigin: comment.origin,
|
||||
})),
|
||||
)
|
||||
},
|
||||
updateViewport: () => setActiveMessage(messages[boundary - 2]),
|
||||
})
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = serverSDK.api.session
|
||||
const messages = actions.session.history.userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
const revertMessageID = actions.session.data.revertMessageID()
|
||||
if (!revertMessageID) return
|
||||
|
||||
const boundary = messages.findIndex((message) => message.id === revertMessageID)
|
||||
if (boundary < 0) return
|
||||
const next = messages[boundary + 1]
|
||||
if (!next) {
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => session.revert.clear({ sessionID }),
|
||||
updatePrompt: (target) => target.reset(),
|
||||
updateViewport: () => setActiveMessage(messages.at(-1)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => session.revert.stage({ sessionID, messageID: next.id }),
|
||||
updatePrompt: () => undefined,
|
||||
updateViewport: () => setActiveMessage(messages[boundary]),
|
||||
})
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
@@ -332,18 +416,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
// TODO: Restore undo when current transcript parts can reconstruct the prompt draft.
|
||||
disabled: true,
|
||||
onSelect: () => undefined,
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
// TODO: Restore redo with the current transcript projection.
|
||||
disabled: true,
|
||||
onSelect: () => undefined,
|
||||
disabled: !actions.session.identity.params.id || !actions.session.data.revertMessageID(),
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserMessage } from "@/types"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { messageIdFromHash } from "./message-id-from-hash"
|
||||
@@ -7,14 +7,14 @@ export const useSessionHashScroll = (input: {
|
||||
sessionKey: () => string
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
visibleUserMessages: () => SessionMessageUser[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setActiveMessage: (message: SessionMessageUser | undefined) => void
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
@@ -85,7 +85,7 @@ export const useSessionHashScroll = (input: {
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
const scrollToMessage = (message: SessionMessageUser, behavior: ScrollBehavior = "smooth") => {
|
||||
cancel()
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
input.revealMessage?.(message.id)
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
FileDiffInfo,
|
||||
ProjectListOutput,
|
||||
WorktreeDirectory,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
|
||||
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
|
||||
@@ -11,16 +6,6 @@ export type Project = Omit<ProjectListOutput[number], "canonical"> & {
|
||||
worktrees: WorktreeDirectory[]
|
||||
}
|
||||
|
||||
type CurrentEvent = EventSubscribeOutput extends infer Item
|
||||
? Item extends { type: infer Type extends string; data: infer Data }
|
||||
? { type: Type; properties: Data }
|
||||
: never
|
||||
: never
|
||||
|
||||
export type Event = CurrentEvent
|
||||
|
||||
export type EventSessionError = Extract<Event, { type: "session.execution.failed" }>
|
||||
|
||||
type MessageError =
|
||||
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||
| { name: "UnknownError"; data: { message: string; ref?: string } }
|
||||
|
||||
@@ -53,6 +53,33 @@ export function readCommentMetadata(value: unknown) {
|
||||
} satisfies PromptComment
|
||||
}
|
||||
|
||||
export function readPromptPresentation(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
const displayText = (value as { displayText?: unknown }).displayText
|
||||
const comments = (value as { comments?: unknown }).comments
|
||||
if (typeof displayText !== "string" || !Array.isArray(comments)) return
|
||||
return {
|
||||
displayText,
|
||||
comments: comments.flatMap((item): PromptComment[] => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const path = (item as { path?: unknown }).path
|
||||
const comment = (item as { comment?: unknown }).comment
|
||||
if (typeof path !== "string" || typeof comment !== "string") return []
|
||||
const preview = (item as { preview?: unknown }).preview
|
||||
const origin = (item as { origin?: unknown }).origin
|
||||
return [
|
||||
{
|
||||
path,
|
||||
comment,
|
||||
selection: selection((item as { selection?: unknown }).selection),
|
||||
preview: typeof preview === "string" ? preview : undefined,
|
||||
origin: origin === "review" || origin === "file" ? origin : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
|
||||
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
|
||||
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message } from "@/types"
|
||||
import { diffs, message } from "./diffs"
|
||||
import { diffs } from "./diffs"
|
||||
|
||||
const item = {
|
||||
file: "src/app.ts",
|
||||
@@ -34,41 +33,3 @@ describe("diffs", () => {
|
||||
).toEqual([item])
|
||||
})
|
||||
})
|
||||
|
||||
describe("message", () => {
|
||||
test("normalizes user summaries with object diffs", () => {
|
||||
const input = {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
summary: {
|
||||
title: "Edit",
|
||||
diffs: { a: item },
|
||||
},
|
||||
} as unknown as Message
|
||||
|
||||
expect(message(input)).toMatchObject({
|
||||
summary: {
|
||||
title: "Edit",
|
||||
diffs: [item],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("drops invalid user summaries", () => {
|
||||
const input = {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
summary: true,
|
||||
} as unknown as Message
|
||||
|
||||
expect(message(input)).toMatchObject({ summary: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message } from "@/types"
|
||||
|
||||
type Diff = FileDiffInfo
|
||||
|
||||
@@ -24,26 +23,3 @@ export function diffs(value: unknown): Diff[] {
|
||||
if (!object(value)) return []
|
||||
return Object.values(value).filter(diff)
|
||||
}
|
||||
|
||||
export function message(value: Message): Message {
|
||||
if (value.role !== "user") return value
|
||||
|
||||
const raw = value.summary as unknown
|
||||
if (raw === undefined) return value
|
||||
if (!object(raw)) return { ...value, summary: undefined }
|
||||
|
||||
const title = typeof raw.title === "string" ? raw.title : undefined
|
||||
const body = typeof raw.body === "string" ? raw.body : undefined
|
||||
const next = diffs(raw.diffs)
|
||||
|
||||
if (title === raw.title && body === raw.body && next === raw.diffs) return value
|
||||
|
||||
return {
|
||||
...value,
|
||||
summary: {
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(body === undefined ? {} : { body }),
|
||||
diffs: next,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Part } from "@/types"
|
||||
import { extractPromptFromParts } from "./prompt"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
|
||||
|
||||
describe("extractPromptFromParts", () => {
|
||||
describe("extractPromptFromMessage", () => {
|
||||
test("restores multiple uploaded attachments", () => {
|
||||
const parts = [
|
||||
{
|
||||
id: "text_1",
|
||||
type: "text",
|
||||
text: "check these",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_1",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
url: "data:image/png;base64,AAA",
|
||||
filename: "a.png",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
{
|
||||
id: "file_2",
|
||||
type: "file",
|
||||
mime: "application/pdf",
|
||||
url: "data:application/pdf;base64,BBB",
|
||||
filename: "b.pdf",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
},
|
||||
] satisfies Part[]
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "check these",
|
||||
files: [
|
||||
{ data: "AAA", mime: "image/png", source: { type: "inline" }, name: "a.png" },
|
||||
{ data: "BBB", mime: "application/pdf", source: { type: "inline" }, name: "b.pdf" },
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
const result = extractPromptFromParts(parts)
|
||||
const result = extractPromptFromMessage(message)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
|
||||
@@ -51,4 +34,75 @@ describe("extractPromptFromParts", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("restores optimistic data URLs and review comments", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "model text",
|
||||
metadata: {
|
||||
displayText: "visible text",
|
||||
comments: [
|
||||
{
|
||||
path: "src/app.ts",
|
||||
comment: "check this",
|
||||
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 4 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
},
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "data:image/png;base64,AAA" },
|
||||
name: "a.png",
|
||||
},
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "visible text" },
|
||||
{ type: "image", filename: "a.png", mime: "image/png" },
|
||||
])
|
||||
expect(extractPromptComments(message)).toMatchObject([
|
||||
{ path: "src/app.ts", comment: "check this", origin: "review" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the directory of a file mention without an at-sign", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "inspect src/client.ts",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/src/client.ts" },
|
||||
name: "client.ts",
|
||||
mention: { text: "src/client.ts", start: 8, end: 21 },
|
||||
},
|
||||
],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(extractPromptFromMessage(message)).toMatchObject([
|
||||
{ type: "text", content: "inspect " },
|
||||
{ type: "file", content: "src/client.ts", path: "src/client.ts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses model text when presentation metadata is incomplete", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "model text",
|
||||
metadata: { displayText: "partial display text" },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { createLegacyBlobReference } from "@/utils/draft-store"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { readPromptPresentation } from "./comment-note"
|
||||
|
||||
type Inline =
|
||||
| {
|
||||
@@ -39,94 +40,69 @@ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["s
|
||||
}
|
||||
}
|
||||
|
||||
function textPartValue(parts: Part[]) {
|
||||
const candidates = parts
|
||||
.filter((part): part is TextPart => part.type === "text")
|
||||
.filter((part) => !part.synthetic && !part.ignored)
|
||||
return candidates.reduce((best: TextPart | undefined, part) => {
|
||||
if (!best) return part
|
||||
if (part.text.length > best.text.length) return part
|
||||
return best
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt content from message parts for restoring into the prompt input.
|
||||
* This is used by undo to restore the original user prompt.
|
||||
*/
|
||||
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
|
||||
const textPart = textPartValue(parts)
|
||||
const text = textPart?.text ?? ""
|
||||
export function extractPromptFromMessage(
|
||||
message: SessionMessageUser,
|
||||
opts?: { directory?: string; attachmentName?: string },
|
||||
): Prompt {
|
||||
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
|
||||
const directory = opts?.directory
|
||||
const attachmentName = opts?.attachmentName ?? "attachment"
|
||||
|
||||
const toRelative = (path: string) => {
|
||||
if (!directory) return path
|
||||
|
||||
const prefix = directory.endsWith("/") ? directory : directory + "/"
|
||||
if (path.startsWith(prefix)) return path.slice(prefix.length)
|
||||
|
||||
if (path.startsWith(directory)) {
|
||||
const next = path.slice(directory.length)
|
||||
if (next.startsWith("/")) return next.slice(1)
|
||||
return next
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const inline: Inline[] = []
|
||||
const images: ImageAttachmentPart[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const filePart = part as FilePart
|
||||
const sourceText = filePart.source?.text
|
||||
if (sourceText) {
|
||||
const value = sourceText.value
|
||||
const start = sourceText.start
|
||||
const end = sourceText.end
|
||||
let path = value
|
||||
if (value.startsWith("@")) path = value.slice(1)
|
||||
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
|
||||
path = filePart.source.path
|
||||
}
|
||||
inline.push({
|
||||
type: "file",
|
||||
start,
|
||||
end,
|
||||
value,
|
||||
path: toRelative(path),
|
||||
selection: selectionFromFileUrl(filePart.url),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (filePart.url.startsWith("data:")) {
|
||||
images.push({
|
||||
type: "image",
|
||||
id: filePart.id,
|
||||
filename: filePart.filename ?? attachmentName,
|
||||
mime: filePart.mime,
|
||||
blob: createLegacyBlobReference(filePart.url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "agent") {
|
||||
const agentPart = part as MessageAgentPart
|
||||
const source = agentPart.source
|
||||
if (!source) continue
|
||||
for (const file of message.files ?? []) {
|
||||
const mention = file.mention
|
||||
const uri = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
|
||||
if (mention) {
|
||||
inline.push({
|
||||
type: "agent",
|
||||
start: source.start,
|
||||
end: source.end,
|
||||
value: source.value,
|
||||
name: agentPart.name,
|
||||
type: "file",
|
||||
start: mention.start,
|
||||
end: mention.end,
|
||||
value: mention.text,
|
||||
path: toRelative(mention.text.startsWith("@") ? mention.text.slice(1) : mention.text),
|
||||
selection: selectionFromFileUrl(uri),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const dataUrl =
|
||||
file.source.type === "uri" && file.source.uri.startsWith("data:")
|
||||
? file.source.uri
|
||||
: file.data
|
||||
? `data:${file.mime};base64,${file.data}`
|
||||
: undefined
|
||||
if (!dataUrl) continue
|
||||
images.push({
|
||||
type: "image",
|
||||
id: `${message.id}:file:${images.length}`,
|
||||
filename: file.name ?? attachmentName,
|
||||
mime: file.mime,
|
||||
blob: createLegacyBlobReference(dataUrl),
|
||||
})
|
||||
}
|
||||
for (const agent of message.agents ?? []) {
|
||||
const mention = agent.mention
|
||||
if (!mention) continue
|
||||
inline.push({
|
||||
type: "agent",
|
||||
start: mention.start,
|
||||
end: mention.end,
|
||||
value: mention.text,
|
||||
name: agent.name,
|
||||
})
|
||||
}
|
||||
return buildPrompt(text, inline, images)
|
||||
}
|
||||
|
||||
export function extractPromptComments(message: SessionMessageUser) {
|
||||
return readPromptPresentation(message.metadata)?.comments ?? []
|
||||
}
|
||||
|
||||
function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart[]): Prompt {
|
||||
inline.sort((a, b) => {
|
||||
if (a.start !== b.start) return a.start - b.start
|
||||
return a.end - b.end
|
||||
|
||||
@@ -1,93 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionMessages } from "./session-message"
|
||||
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { presentAssistantParts, presentUserParts } from "./session-message"
|
||||
|
||||
describe("normalizeSessionMessages", () => {
|
||||
test("projects current turns into stable timeline rendering records", () => {
|
||||
const source = [
|
||||
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "model-switched",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
id: "msg_3",
|
||||
type: "user",
|
||||
text: "inspect @src/client.ts",
|
||||
files: [
|
||||
{
|
||||
data: "aGVsbG8=",
|
||||
mime: "text/plain",
|
||||
name: "note.txt",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
{
|
||||
data: "ZXhwb3J0IHt9",
|
||||
mime: "text/plain",
|
||||
name: "client.ts",
|
||||
source: { type: "inline" },
|
||||
mention: { text: "@src/client.ts", start: 8, end: 22 },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_4",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
|
||||
{ type: "text", text: "Result" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "note.txt" },
|
||||
metadata: { title: "note.txt" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
time: { created: 5, ran: 6, completed: 7 },
|
||||
},
|
||||
],
|
||||
cost: 0.1,
|
||||
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
|
||||
time: { created: 4, completed: 7 },
|
||||
},
|
||||
{
|
||||
id: "msg_5",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: 8 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
describe("session message presentation", () => {
|
||||
test("projects current user content for the DOM renderer", () => {
|
||||
const message = {
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
text: "inspect @src/client.ts",
|
||||
files: [
|
||||
{
|
||||
data: "ZXhwb3J0IHt9",
|
||||
mime: "text/plain",
|
||||
name: "client.ts",
|
||||
source: { type: "inline" },
|
||||
mention: { text: "@src/client.ts", start: 8, end: 22 },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
const parts = presentUserParts("ses_1", message)
|
||||
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
id: "msg_3",
|
||||
role: "user",
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
|
||||
})
|
||||
expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
|
||||
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
|
||||
"msg_3:text:0",
|
||||
"msg_3:file:0",
|
||||
"msg_3:file:1",
|
||||
"msg_3:agent:0",
|
||||
"msg_5:compaction",
|
||||
])
|
||||
expect(result.parts.get("msg_3")?.[2]).toMatchObject({
|
||||
expect(parts.map((part) => part.id)).toEqual(["msg_user:text:0", "msg_user:file:0", "msg_user:agent:0"])
|
||||
expect(parts[1]).toMatchObject({
|
||||
type: "file",
|
||||
source: {
|
||||
type: "file",
|
||||
@@ -95,109 +32,86 @@ describe("normalizeSessionMessages", () => {
|
||||
text: { value: "@src/client.ts", start: 8, end: 22 },
|
||||
},
|
||||
})
|
||||
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
|
||||
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: { status: "completed", output: "hello" },
|
||||
|
||||
const plainMention = {
|
||||
...message,
|
||||
text: "inspect src/client.ts",
|
||||
files: [
|
||||
{
|
||||
...message.files[0],
|
||||
name: "client.ts",
|
||||
mention: { text: "src/client.ts", start: 8, end: 21 },
|
||||
},
|
||||
],
|
||||
} satisfies SessionMessageUser
|
||||
expect(presentUserParts("ses_1", plainMention)[1]).toMatchObject({
|
||||
type: "file",
|
||||
source: { type: "file", path: "src/client.ts" },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not invent a parent for an assistant-only page", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "orphan" }],
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
|
||||
})
|
||||
|
||||
test("projects a current shell message into a renderable standalone turn", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_shell",
|
||||
type: "shell",
|
||||
shellID: "shell_1",
|
||||
command: "printf hello",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
output: { output: "hello", cursor: 5, size: 5, truncated: false },
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
expect.objectContaining({ id: "msg_shell", role: "user" }),
|
||||
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
|
||||
])
|
||||
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
|
||||
expect(result.parts.get("msg_shell:assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: { command: "printf hello" },
|
||||
output: "hello",
|
||||
title: "Shell",
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("adapts current edit fields for the edit renderer", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
|
||||
content: [{ type: "text", text: "Edited file successfully" }],
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "README.md",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
},
|
||||
],
|
||||
replacements: 1,
|
||||
},
|
||||
},
|
||||
time: { created: 2, ran: 3, completed: 4 },
|
||||
test("projects current assistant content for existing DOM tools", () => {
|
||||
const message = {
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
|
||||
{ type: "text", text: "Result" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "note.txt" },
|
||||
metadata: { title: "note.txt" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
time: { created: 3, ran: 4, completed: 5 },
|
||||
},
|
||||
],
|
||||
cost: 0.1,
|
||||
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
|
||||
time: { created: 2, completed: 5 },
|
||||
} satisfies SessionMessageAssistant
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
const parts = presentAssistantParts("ses_1", message)
|
||||
|
||||
expect(result.parts.get("msg_assistant")).toEqual([
|
||||
expect(parts.map((part) => part.id)).toEqual(["msg_assistant:reasoning:0", "msg_assistant:text:0", "call_1"])
|
||||
expect(parts[2]).toMatchObject({ type: "tool", tool: "read", state: { status: "completed", output: "hello" } })
|
||||
})
|
||||
|
||||
test("adapts current edit fields only at the renderer boundary", () => {
|
||||
const message = {
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
|
||||
content: [{ type: "text", text: "Edited file successfully" }],
|
||||
metadata: {
|
||||
files: [{ file: "README.md", patch: "@@ -1 +1 @@\n-old\n+new", additions: 1, deletions: 1 }],
|
||||
},
|
||||
},
|
||||
time: { created: 2, ran: 3, completed: 4 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 4 },
|
||||
} satisfies SessionMessageAssistant
|
||||
|
||||
expect(presentAssistantParts("ses_1", message)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "edit",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
|
||||
metadata: expect.objectContaining({
|
||||
filediff: {
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionMessageShell,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@/types"
|
||||
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createCommentMetadata, formatCommentNote, readPromptPresentation } from "./comment-note"
|
||||
|
||||
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export function compareMessages(a: Pick<Message, "id" | "time">, b: Pick<Message, "id" | "time">) {
|
||||
const left = messageKey(a)
|
||||
const right = messageKey(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
export const messageKey = (message: Pick<Message, "id" | "time">) => message.time.created + message.id
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
@@ -45,148 +35,11 @@ function normalizeToolMetadata(name: string, metadata: Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
|
||||
const messages: Message[] = []
|
||||
const parts = new Map<string, Part[]>()
|
||||
let agent = ""
|
||||
let model = emptyModel
|
||||
let parentID: string | undefined
|
||||
|
||||
source.forEach((message) => {
|
||||
if (message.type === "agent-switched") {
|
||||
agent = message.agent
|
||||
return
|
||||
}
|
||||
if (message.type === "model-switched") {
|
||||
model = message.model
|
||||
return
|
||||
}
|
||||
if (message.type === "user") {
|
||||
parentID = message.id
|
||||
messages.push(userMessage(sessionID, message, agent, model))
|
||||
parts.set(message.id, userParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type === "synthetic" && message.description?.trim()) {
|
||||
parentID = message.id
|
||||
messages.push({
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: message.time,
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
})
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
|
||||
return
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
messages.push(...shellMessages(sessionID, message, agent, model))
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
|
||||
parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
|
||||
parentID = undefined
|
||||
return
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
agent = message.agent
|
||||
model = message.model
|
||||
if (!parentID) return
|
||||
const parent = messages.findLast((item) => item.id === parentID)
|
||||
if (parent?.role === "user") {
|
||||
parent.agent = message.agent
|
||||
parent.model = {
|
||||
providerID: message.model.providerID,
|
||||
modelID: message.model.id,
|
||||
variant: message.model.variant,
|
||||
}
|
||||
}
|
||||
messages.push(assistantMessage(sessionID, parentID, message))
|
||||
parts.set(message.id, assistantParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type !== "compaction" || !parentID) return
|
||||
parts.set(parentID, [
|
||||
...(parts.get(parentID) ?? []),
|
||||
{
|
||||
id: `${message.id}:compaction`,
|
||||
sessionID,
|
||||
messageID: parentID,
|
||||
type: "compaction",
|
||||
auto: message.reason === "auto",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return { messages, parts }
|
||||
}
|
||||
|
||||
function shellMessages(
|
||||
sessionID: string,
|
||||
message: SessionMessageShell,
|
||||
agent: string,
|
||||
model: { id: string; providerID: string; variant?: string },
|
||||
): [UserMessage, AssistantMessage] {
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: message.time.created },
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
},
|
||||
{
|
||||
id: `${message.id}:assistant`,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: message.time,
|
||||
parentID: message.id,
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
variant: model.variant,
|
||||
mode: agent,
|
||||
agent,
|
||||
path: { cwd: "", root: "" },
|
||||
cost: 0,
|
||||
tokens: emptyTokens,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
|
||||
const input = { command: message.command }
|
||||
const start = message.time.created
|
||||
const state: ToolPart["state"] =
|
||||
message.status === "running"
|
||||
? { status: "running", input, time: { start } }
|
||||
: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: message.output?.output ?? "",
|
||||
title: "Shell",
|
||||
metadata: {
|
||||
status: message.status,
|
||||
exit: message.exit,
|
||||
truncated: message.output?.truncated,
|
||||
},
|
||||
time: { start, end: message.time.completed ?? start },
|
||||
}
|
||||
return {
|
||||
id: `${message.id}:tool`,
|
||||
sessionID,
|
||||
messageID: `${message.id}:assistant`,
|
||||
type: "tool",
|
||||
callID: message.shellID,
|
||||
tool: "bash",
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
|
||||
return `${messageID}:${type}:${ordinal}`
|
||||
}
|
||||
|
||||
function userMessage(
|
||||
export function presentUserMessage(
|
||||
sessionID: string,
|
||||
message: SessionMessageUser,
|
||||
agent: string,
|
||||
@@ -202,9 +55,11 @@ function userMessage(
|
||||
}
|
||||
}
|
||||
|
||||
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
const presentation = readPromptPresentation(message.metadata)
|
||||
const text = presentation?.displayText ?? message.text
|
||||
return [
|
||||
...(message.text ? [textPart(sessionID, message.id, 0, message.text)] : []),
|
||||
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
|
||||
...(message.files ?? []).map(
|
||||
(file, index): FilePart => ({
|
||||
id: `${message.id}:file:${index}`,
|
||||
@@ -218,7 +73,7 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
? {
|
||||
type: "file",
|
||||
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
|
||||
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text),
|
||||
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : file.mention.text,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
@@ -235,10 +90,25 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
...(presentation?.comments ?? []).map(
|
||||
(comment, index): Part => ({
|
||||
id: `${message.id}:comment:${index}`,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "text",
|
||||
text: formatCommentNote(comment),
|
||||
synthetic: true,
|
||||
metadata: createCommentMetadata(comment),
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
|
||||
export function presentAssistantMessage(
|
||||
sessionID: string,
|
||||
parentID: string,
|
||||
message: SessionMessageAssistant,
|
||||
): AssistantMessage {
|
||||
const error = message.error
|
||||
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
|
||||
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
|
||||
@@ -263,32 +133,40 @@ function assistantMessage(sessionID: string, parentID: string, message: SessionM
|
||||
}
|
||||
}
|
||||
|
||||
function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
|
||||
export function presentAssistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.content.flatMap((content): Part[] => {
|
||||
if (content.type === "text") {
|
||||
const part = textPart(sessionID, message.id, ordinals.text++, content.text)
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
if (content.type === "reasoning") {
|
||||
const part: Part = {
|
||||
id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "reasoning",
|
||||
text: content.text,
|
||||
metadata: content.state,
|
||||
time: {
|
||||
start: content.time?.created ?? message.time.created,
|
||||
end: content.time?.completed,
|
||||
},
|
||||
}
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
return [toolPart(sessionID, message.id, content)]
|
||||
const id =
|
||||
content.type === "tool" ? content.id : sessionMessagePartID(message.id, content.type, ordinals[content.type]++)
|
||||
const part = presentAssistantContent(sessionID, message, id, content)
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return []
|
||||
return [part]
|
||||
})
|
||||
}
|
||||
|
||||
export function presentAssistantContent(
|
||||
sessionID: string,
|
||||
message: SessionMessageAssistant,
|
||||
id: string,
|
||||
content: SessionMessageAssistant["content"][number],
|
||||
): Part {
|
||||
if (content.type === "text") return { id, sessionID, messageID: message.id, type: "text", text: content.text }
|
||||
if (content.type === "reasoning")
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "reasoning",
|
||||
text: content.text,
|
||||
metadata: content.state,
|
||||
time: {
|
||||
start: content.time?.created ?? message.time.created,
|
||||
end: content.time?.completed,
|
||||
},
|
||||
}
|
||||
return toolPart(sessionID, message.id, content)
|
||||
}
|
||||
|
||||
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
|
||||
return {
|
||||
id: sessionMessagePartID(messageID, "text", ordinal),
|
||||
@@ -312,7 +190,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
|
||||
time: { start },
|
||||
}
|
||||
@@ -322,7 +199,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
|
||||
status: "error" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
error: tool.state.error.message,
|
||||
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
}
|
||||
@@ -347,7 +223,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
|
||||
title: tool.name,
|
||||
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { action, type Policy } from "./updater-action"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
|
||||
|
||||
const packageName =
|
||||
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
|
||||
@@ -68,6 +68,14 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const binary = path.join(
|
||||
global.home,
|
||||
".opencode",
|
||||
"bin",
|
||||
process.platform === "win32" ? "opencode2.exe" : "opencode2",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
@@ -104,21 +112,33 @@ export const layer = Layer.effect(
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Exclude<Method, "bun">, string[]> = {
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* method === "bun"
|
||||
? Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--cache-dir", cache, target], "5 minutes")
|
||||
}),
|
||||
)
|
||||
: run(commands[method], "5 minutes")
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* run(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
if (download.code !== 0) return download
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
}),
|
||||
)
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface Interface {
|
||||
| "command"
|
||||
| "rename"
|
||||
| "resume"
|
||||
| "switchAgent"
|
||||
| "switchModel"
|
||||
| "interrupt"
|
||||
| "synthetic"
|
||||
| "wait"
|
||||
@@ -75,6 +77,8 @@ export const layerWithCell = (cell: Cell) =>
|
||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
||||
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
|
||||
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||
|
||||
@@ -14,7 +14,7 @@ export const name = "subagent"
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
||||
[
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
|
||||
`The subagent is working in the background (sessionID: ${sessionID}). You will be notified automatically when it finishes.`,
|
||||
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
|
||||
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
|
||||
].join("\n")
|
||||
@@ -23,6 +23,10 @@ export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
|
||||
description:
|
||||
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
|
||||
}),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
||||
@@ -36,7 +40,8 @@ export const Output = Schema.Struct({
|
||||
})
|
||||
export const description = [
|
||||
"Spawns an agent in a child session to work on the specified task.",
|
||||
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
|
||||
"The output includes a sessionID you can pass back later to continue that specific conversation with the subagent.",
|
||||
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
@@ -50,6 +55,9 @@ export const Plugin = {
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
// One completion observer per job generation. Keyed by child plus start time so a fresh
|
||||
// continuation job is observable even while a settled generation's observer is finalizing.
|
||||
const notifications = new Set<string>()
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
@@ -77,7 +85,7 @@ export const Plugin = {
|
||||
) {
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID: parentID,
|
||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
description,
|
||||
metadata: { source: "subagent", childID, agent, state },
|
||||
})
|
||||
@@ -88,7 +96,11 @@ export const Plugin = {
|
||||
childID: SessionSchema.ID,
|
||||
agent: string,
|
||||
description: string,
|
||||
startedAt: number,
|
||||
) {
|
||||
const key = `${childID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* runtime.job.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
@@ -106,6 +118,7 @@ export const Plugin = {
|
||||
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
@@ -163,33 +176,74 @@ export const Plugin = {
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
||||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child = yield* runtime.session
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
|
||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
||||
const existing =
|
||||
input.sessionID === undefined
|
||||
? undefined
|
||||
: yield* runtime.session
|
||||
.get(input.sessionID)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({ message: `Subagent session not found: ${input.sessionID}`, error }),
|
||||
),
|
||||
)
|
||||
if (existing !== undefined && existing.parentID !== context.sessionID)
|
||||
return yield* new ToolFailure({
|
||||
message: `Session ${existing.id} is not a child of the current session`,
|
||||
})
|
||||
.pipe(
|
||||
// Continuing with a different agent switches the child, mirroring create semantics
|
||||
// where the agent's configured model wins over the inherited one.
|
||||
if (existing !== undefined && existing.agent !== agent.id) {
|
||||
yield* runtime.session.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
|
||||
Effect.andThen(
|
||||
agent.model === undefined
|
||||
? Effect.void
|
||||
: runtime.session.switchModel({ sessionID: existing.id, model: agent.model }),
|
||||
),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
(error) =>
|
||||
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child =
|
||||
existing ??
|
||||
(yield* runtime.session
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
||||
),
|
||||
))
|
||||
|
||||
const background = input.background === true
|
||||
yield* context.progress({ sessionID: child.id, status: "running" })
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* runtime.session.prompt({
|
||||
// Standard prompt admission outside the job: Job.start joining a running child skips
|
||||
// its run effect, and the default wake starts an idle child or steers a running one.
|
||||
yield* runtime.session
|
||||
.prompt({
|
||||
sessionID: child.id,
|
||||
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
|
||||
resume: false,
|
||||
text:
|
||||
existing === undefined
|
||||
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
|
||||
: input.prompt,
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Failed to prompt subagent: ${child.id}`, error }),
|
||||
),
|
||||
)
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
yield* runtime.session.resume(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
||||
@@ -204,7 +258,7 @@ export const Plugin = {
|
||||
|
||||
if (background) {
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description, info.started_at)
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "running" as const,
|
||||
@@ -220,21 +274,34 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
|
||||
yield* notifyWhenDone(
|
||||
context.sessionID,
|
||||
child.id,
|
||||
agent.name,
|
||||
input.description,
|
||||
result.info.started_at,
|
||||
)
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "running" as const,
|
||||
output: backgroundStarted(child.id),
|
||||
}
|
||||
}
|
||||
// Failure surfaces keep the sessionID visible so the model can continue the child.
|
||||
if (result?.info.status === "error")
|
||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
|
||||
return yield* new ToolFailure({
|
||||
message: `Subagent failed (sessionID: ${child.id}): ${result.info.error ?? "unknown error"}`,
|
||||
})
|
||||
if (result?.info.status === "cancelled")
|
||||
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: output.output,
|
||||
content:
|
||||
output.status === "completed"
|
||||
? `<subagent sessionID="${output.sessionID}" state="completed">\n${output.output}\n</subagent>`
|
||||
: output.output,
|
||||
metadata: { sessionID: output.sessionID, status: output.status },
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -34,6 +34,8 @@ import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
const childText = "child final response"
|
||||
const completedOutput = (sessionID: Session.ID) =>
|
||||
`<subagent sessionID="${sessionID}" state="completed">\n${childText}\n</subagent>`
|
||||
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
@@ -252,7 +254,7 @@ describe("SubagentTool", () => {
|
||||
expect(settled).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { status: "completed" },
|
||||
content: [{ type: "text", text: childText }],
|
||||
content: [{ type: "text", text: expect.stringContaining(childText) }],
|
||||
})
|
||||
expect(settled.metadata).toEqual({
|
||||
sessionID: outputSessionID(settled.metadata),
|
||||
@@ -294,9 +296,10 @@ describe("SubagentTool", () => {
|
||||
expect(settled).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { status: "completed" },
|
||||
content: [{ type: "text", text: childText }],
|
||||
content: [{ type: "text", text: expect.stringContaining(childText) }],
|
||||
})
|
||||
const child = yield* sessions.get(outputSessionID(settled.metadata))
|
||||
expect(settled.content).toEqual([{ type: "text", text: completedOutput(child.id) }])
|
||||
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
|
||||
expect(progress[0]).toEqual({ sessionID: child.id, status: "running" })
|
||||
expect(child).toMatchObject({
|
||||
@@ -326,6 +329,188 @@ describe("SubagentTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("continues an existing child session", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
|
||||
const first = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent-first",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "review", prompt: "review this" },
|
||||
},
|
||||
})
|
||||
const childID = outputSessionID(first.metadata)
|
||||
const second = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent-second",
|
||||
name: SubagentTool.name,
|
||||
input: {
|
||||
agent: "reviewer",
|
||||
description: "follow up",
|
||||
prompt: "continue this",
|
||||
sessionID: childID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(outputSessionID(second.metadata)).toBe(childID)
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
|
||||
expect((yield* sessions.get(childID)).title).toBe("review")
|
||||
expect(
|
||||
(yield* sessions.inbox(childID)).flatMap((message) =>
|
||||
message.type === "user" ? [message.payload.text] : [],
|
||||
),
|
||||
).toEqual(["You are a subagent spawned by another session.\nreview this", "continue this"])
|
||||
expect(second.content).toEqual([{ type: "text", text: completedOutput(childID) }])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("steers a running child session in the background", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
const child = yield* sessions.create({
|
||||
parentID: parent.id,
|
||||
title: "review",
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
model: childModel,
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const jobs = yield* Job.Service
|
||||
yield* jobs.start({ id: child.id, type: SubagentTool.name, run: Effect.never })
|
||||
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-running-subagent",
|
||||
name: SubagentTool.name,
|
||||
input: {
|
||||
agent: "reviewer",
|
||||
description: "follow up",
|
||||
prompt: "continue while running",
|
||||
sessionID: child.id,
|
||||
background: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { sessionID: child.id, status: "running" },
|
||||
})
|
||||
expect((yield* sessions.inbox(child.id)).find((message) => message.type === "user")?.payload.text).toBe(
|
||||
"continue while running",
|
||||
)
|
||||
expect((yield* jobs.get(child.id))?.status).toBe("running")
|
||||
yield* jobs.cancel(child.id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects unrelated children and switches agents on continuation", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* Session.Service
|
||||
const parent = yield* sessions.create({ location, model: parentModel })
|
||||
const otherParent = yield* sessions.create({ location, model: parentModel })
|
||||
const unrelated = yield* sessions.create({
|
||||
parentID: otherParent.id,
|
||||
title: "other review",
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
})
|
||||
const switched = yield* sessions.create({
|
||||
parentID: parent.id,
|
||||
title: "fallback review",
|
||||
agent: Agent.ID.make("fallback"),
|
||||
model: parentModel,
|
||||
})
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const call = (sessionID: Session.ID, id: string, agent = "reviewer") =>
|
||||
executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call" as const,
|
||||
id,
|
||||
name: SubagentTool.name,
|
||||
input: { agent, description: "follow up", prompt: "continue", sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
const missing = Session.ID.create()
|
||||
expect(yield* call(missing, "call-missing-child")).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: `Subagent session not found: ${missing}`,
|
||||
},
|
||||
})
|
||||
expect(yield* call(unrelated.id, "call-unrelated-child")).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: `Session ${unrelated.id} is not a child of the current session`,
|
||||
},
|
||||
})
|
||||
expect(yield* call(switched.id, "call-switched-child")).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { sessionID: switched.id, status: "completed" },
|
||||
})
|
||||
expect(yield* sessions.get(switched.id)).toMatchObject({
|
||||
agent: "reviewer",
|
||||
model: childModel,
|
||||
})
|
||||
// Switching to an agent without a configured model keeps the child's current model.
|
||||
expect(yield* call(switched.id, "call-modelless-switch", "fallback")).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { sessionID: switched.id, status: "completed" },
|
||||
})
|
||||
expect(yield* sessions.get(switched.id)).toMatchObject({
|
||||
agent: "fallback",
|
||||
model: childModel,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns child runner failures as tool errors", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -399,12 +584,12 @@ describe("SubagentTool", () => {
|
||||
status: "running",
|
||||
})
|
||||
expect(settled.metadata).toEqual({ sessionID: childID, status: "running" })
|
||||
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
|
||||
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`sessionID: ${childID}`) }])
|
||||
|
||||
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
||||
expect(admission?.data.item.type).toBe("synthetic")
|
||||
if (admission?.data.item.type !== "synthetic") return yield* Effect.die("Expected synthetic inbox item")
|
||||
expect(admission?.data.item.payload.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
expect(admission?.data.item.payload.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
|
||||
expect(admission?.data.item.payload).toMatchObject({
|
||||
description: "background review",
|
||||
metadata: {
|
||||
@@ -418,7 +603,7 @@ describe("SubagentTool", () => {
|
||||
yield* SessionInbox.promote(database.db, bus, parent.id, "steer")
|
||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(synthetic).toHaveLength(1)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
expect(synthetic[0]?.text).toContain(`<subagent sessionID="${childID}" state="completed"`)
|
||||
expect(synthetic[0]?.text).toContain(childText)
|
||||
}),
|
||||
),
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { open } from "node:fs/promises"
|
||||
import { nativeT } from "./native-translations"
|
||||
import { nativeT } from "../native/translations"
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
import { clipboard, dialog, shell } from "electron"
|
||||
import type { DirectoryPickerOptions, FilePickerOptions, SaveFilePickerOptions } from "../../shared/ipc-contract"
|
||||
import { writeLog } from "../native/logging"
|
||||
import { nativeT } from "../native/translations"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
|
||||
|
||||
export function createFileCapabilities() {
|
||||
const pickedFiles = createPickedFileAuthorizations()
|
||||
|
||||
return {
|
||||
async openDirectoryPicker(options?: DirectoryPickerOptions) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
|
||||
defaultPath: options?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return options?.multiple ? result.filePaths : result.filePaths[0]
|
||||
},
|
||||
async openFilePicker(sender: number, options?: FilePickerOptions) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
|
||||
defaultPath: options?.defaultPath,
|
||||
filters: pickerFilters(options?.extensions),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (path) => ({ path, name: basename(path), size: (await stat(path)).size })),
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
return { token: pickedFiles.add(sender, result.filePaths), files }
|
||||
},
|
||||
readPickedFile: (sender: number, token: string, path: string) => pickedFiles.read(sender, token, path),
|
||||
releasePickedFiles: (sender: number, token: string) => pickedFiles.release(sender, token),
|
||||
async saveFilePicker(options?: SaveFilePickerOptions) {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
|
||||
defaultPath: options?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
},
|
||||
async openPath(path: string, application?: string) {
|
||||
if (!application) return shell.openPath(path)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? { file: "open", arguments: ["-a", application, path] }
|
||||
: { file: application, arguments: [path] }
|
||||
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
},
|
||||
async revealPath(path: string) {
|
||||
const exists = await stat(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (!exists) return false
|
||||
shell.showItemInFolder(path)
|
||||
return true
|
||||
},
|
||||
readClipboardImage() {
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) return null
|
||||
const size = image.getSize()
|
||||
return { buffer: new Uint8Array(image.toPNG()).buffer, width: size.width, height: size.height }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function openExternalURL(value: string) {
|
||||
const url = resolveExternalURL(value)
|
||||
if (!url) {
|
||||
writeLog("window", "blocked external target", { url: value }, "warn")
|
||||
return
|
||||
}
|
||||
void shell.openExternal(url)
|
||||
}
|
||||
|
||||
export function openLocalFileURL(value: string) {
|
||||
const path = resolveLocalFilePath(value)
|
||||
if (!path) {
|
||||
writeLog("window", "blocked local file target", { url: value }, "warn")
|
||||
return
|
||||
}
|
||||
void shell.openPath(path).then((error) => {
|
||||
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
|
||||
})
|
||||
}
|
||||
|
||||
function pickerFilters(extensions?: string[]) {
|
||||
if (!extensions?.length) return undefined
|
||||
return [{ name: nativeT("desktop.dialog.files"), extensions }]
|
||||
}
|
||||
@@ -1,358 +1,94 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import * as http from "node:http"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
|
||||
import { app } from "electron"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import contextMenu from "electron-context-menu"
|
||||
|
||||
import type { ServerReadyData } from "../shared/ipc-contract"
|
||||
import { checkAppExists, resolveAppPath } from "./apps"
|
||||
import { CHANNEL, VERSION } from "./constants"
|
||||
import { checkAppExists, resolveAppPath } from "./files/apps"
|
||||
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
registerUpdaterIpcHandlers,
|
||||
registerWslIpcHandlers,
|
||||
sendDeepLinks,
|
||||
sendMenuCommand,
|
||||
} from "./ipc"
|
||||
import { forwardInitializationFailure } from "./initialization"
|
||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||
import { createMenu } from "./menu"
|
||||
import {
|
||||
finishFirstLaunchOnboarding,
|
||||
initializeFirstLaunchOnboarding,
|
||||
isFirstLaunchOnboardingPending,
|
||||
} from "./onboarding"
|
||||
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
|
||||
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import { safeWebContentsURL } from "./window-state"
|
||||
import {
|
||||
getLastFocusedWindow,
|
||||
registerRendererProtocol,
|
||||
setRelaunchHandler,
|
||||
setAppQuitting,
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
restoreMainWindows,
|
||||
} from "./windows"
|
||||
import { createWslIpc } from "./wsl/ipc"
|
||||
import { cleanupStoreFiles } from "./store-cleanup"
|
||||
import { startBackgroundCli } from "./background-cli"
|
||||
import { setNativeTranslations } from "./native-translations"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const APP_IDS: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
let logger: ReturnType<typeof initLogging>
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
// Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2.
|
||||
;(http as any).setGlobalProxyFromEnv()
|
||||
} catch (error) {
|
||||
logger.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
function emitDeepLinks(urls: string[]) {
|
||||
if (urls.length === 0) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendDeepLinks(win, urls)
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
const upsert = (key: string) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
.split(",")
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => Boolean(value))
|
||||
|
||||
for (const host of loopback) {
|
||||
if (items.some((value: string) => value.toLowerCase() === host)) continue
|
||||
items.push(host)
|
||||
}
|
||||
|
||||
process.env[key] = items.join(",")
|
||||
}
|
||||
|
||||
upsert("NO_PROXY")
|
||||
upsert("no_proxy")
|
||||
}
|
||||
acquireApplicationLock,
|
||||
configureApplication,
|
||||
loadProxyEnvironment,
|
||||
preferApplicationEnvironment,
|
||||
prepareDesktop,
|
||||
} from "./lifecycle/environment"
|
||||
import { createApplicationLifecycle } from "./lifecycle"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./lifecycle/onboarding"
|
||||
import { exportDebugLogs, startNetworkLogging, writeLog } from "./native/logging"
|
||||
import { createMenu, sendMenuCommand } from "./native/menu"
|
||||
import { setNativeTranslations } from "./native/translations"
|
||||
import { startBackgroundCli } from "./service/background-service"
|
||||
import { forwardInitializationFailure } from "./service/initialization"
|
||||
import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-settings"
|
||||
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
|
||||
import { startWsl } from "./wsl/start"
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
// on macOS apps run in `/` which can cause issues with ripgrep
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
const onboardingTestRoot = ((): string | undefined => {
|
||||
if (!TEST_ONBOARDING) return
|
||||
|
||||
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = join(root, "state")
|
||||
return root
|
||||
})()
|
||||
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath(
|
||||
"userData",
|
||||
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
|
||||
)
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
logger = initLogging()
|
||||
initCrashReporter()
|
||||
|
||||
let stopWslServers = async () => {}
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers().finally(() => {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
|
||||
logger.log("app starting", {
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingTestRoot),
|
||||
})
|
||||
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv()
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) {
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers()
|
||||
})
|
||||
|
||||
app.on("child-process-gone", (_event, details) => {
|
||||
writeLog("utility", "child process gone", { details }, "error")
|
||||
})
|
||||
|
||||
app.on("render-process-gone", (_event, webContents, details) => {
|
||||
writeLog("window", "app render process gone", { url: safeWebContentsURL(webContents), details }, "error")
|
||||
})
|
||||
|
||||
setRelaunchHandler(() => {
|
||||
relaunch()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
setAppQuitting()
|
||||
void stopWslServers().finally(() => app.quit())
|
||||
})
|
||||
}
|
||||
|
||||
const logger = configureApplication()
|
||||
if (!acquireApplicationLock()) return
|
||||
preferApplicationEnvironment(logger)
|
||||
const lifecycle = createApplicationLifecycle(logger)
|
||||
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
|
||||
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
yield* prepareDesktop(logger)
|
||||
|
||||
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
if (result.deleted.length === 0) return
|
||||
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
logger.warn("failed to clean scoped store files", error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
const updater = setupAutoUpdater(() => stopWslServers())
|
||||
const menuDeps = {
|
||||
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
|
||||
const menu = {
|
||||
trigger: (id: string) => {
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||
relaunch,
|
||||
relaunch: lifecycle.relaunch,
|
||||
}
|
||||
registerIpcHandlers({
|
||||
relaunch,
|
||||
relaunch: lifecycle.relaunch,
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* () {
|
||||
logger.log("awaiting server ready")
|
||||
const res = yield* Deferred.await(serverReady)
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
const result = yield* Deferred.await(serverReady)
|
||||
logger.log("server ready", { url: result.url })
|
||||
return result
|
||||
},
|
||||
(e) => Effect.runPromise(e),
|
||||
(effect) => Effect.runPromise(effect),
|
||||
),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
consumeInitialDeepLinks: lifecycle.consumeInitialDeepLinks,
|
||||
getDefaultServerUrl,
|
||||
setDefaultServerUrl,
|
||||
isFirstLaunchOnboardingPending,
|
||||
finishFirstLaunchOnboarding,
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
checkAppExists,
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
showUpdater: () => showUpdaterDialog(updater),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
exportDebugLogs: () => exportDebugLogs(),
|
||||
setBackgroundColor,
|
||||
exportDebugLogs,
|
||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||
setNativeTranslations: (bundle) => {
|
||||
if (setNativeTranslations(bundle)) createMenu(menuDeps)
|
||||
if (setNativeTranslations(bundle)) createMenu(menu)
|
||||
},
|
||||
})
|
||||
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
updateTimer.unref()
|
||||
app.once("will-quit", () => clearInterval(updateTimer))
|
||||
yield* Effect.promise(() => startNetLog()).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
logger.warn("failed to start net log", error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
startAutoUpdater(updater)
|
||||
yield* Effect.promise(() => startNetworkLogging())
|
||||
|
||||
const loadingTask = yield* Effect.gen(function* () {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
|
||||
loadProxyEnvironment(logger)
|
||||
logger.log("starting v2 background service")
|
||||
const background = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
stopWslServers = yield* Effect.promise(() => startWslServers(background))
|
||||
|
||||
const wsl = yield* Effect.promise(() => startWsl(background, logger))
|
||||
registerWslIpcHandlers(wsl.ipc)
|
||||
wsl.start()
|
||||
lifecycle.setWslShutdown(wsl.stop)
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url: background.url,
|
||||
username: background.username,
|
||||
password: background.password,
|
||||
})
|
||||
|
||||
logger.log("loading task finished")
|
||||
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
|
||||
|
||||
yield* Fiber.await(loadingTask)
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform === "darwin") return
|
||||
app.quit()
|
||||
})
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length > 0) return
|
||||
restoreMainWindows()
|
||||
})
|
||||
|
||||
const windows = restoreMainWindows()
|
||||
if (windows.length) createMenu(menuDeps)
|
||||
if (lifecycle.restoreWindows().length) createMenu(menu)
|
||||
})
|
||||
|
||||
async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) {
|
||||
if (process.platform !== "win32") {
|
||||
registerWslIpcHandlers(createWslIpc())
|
||||
return async () => {}
|
||||
}
|
||||
|
||||
const { createWslServersController } = await import("./wsl/servers")
|
||||
const { spawnWslSidecar } = await import("./wsl/sidecar")
|
||||
const local = cli.wslBuild
|
||||
const controller = createWslServersController({
|
||||
cli: { version: cli.version },
|
||||
installCli: local
|
||||
? async (distro) => {
|
||||
const { buildLocalWslCli } = await import("./wsl/local")
|
||||
const { installWslCli } = await import("./wsl/runtime")
|
||||
await installWslCli(distro, {
|
||||
version: cli.version,
|
||||
binary: await buildLocalWslCli({ ...local, version: cli.version }),
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
spawnSidecar: async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
logger: {
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
})
|
||||
registerWslIpcHandlers(createWslIpc(controller))
|
||||
controller.startConfiguredServers()
|
||||
return async () => controller.stopServers()
|
||||
}
|
||||
|
||||
Effect.runFork(main)
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { basename, join } from "node:path"
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import { BrowserWindow, ipcMain } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
|
||||
import {
|
||||
Ipc,
|
||||
sendIpcEvent,
|
||||
type FatalRendererError,
|
||||
type IpcInvoke,
|
||||
type IpcInvokeArgs,
|
||||
@@ -15,21 +11,11 @@ import {
|
||||
type IpcSend,
|
||||
type ServerReadyData,
|
||||
} from "../shared/ipc-contract"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { setForceFocus } from "./debug"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||
import {
|
||||
getPinchZoomEnabled,
|
||||
getWindowID,
|
||||
openExternalURL,
|
||||
openLocalFileURL,
|
||||
setPinchZoomEnabled,
|
||||
setTitlebar,
|
||||
updateTitlebar,
|
||||
} from "./windows"
|
||||
import { createDesktopDraftStore } from "./draft-store"
|
||||
import { nativeT } from "./native-translations"
|
||||
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./files"
|
||||
import { setForceFocus } from "./native/debug"
|
||||
import { runDesktopMenuAction } from "./native/menu-actions"
|
||||
import { createDesktopStorage } from "./storage"
|
||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import type { UpdaterIpc } from "./updater"
|
||||
import type { WslIpc } from "./wsl/ipc"
|
||||
|
||||
@@ -49,13 +35,6 @@ function on<Channel extends keyof IpcSend>(
|
||||
ipcMain.on(channel, listener)
|
||||
}
|
||||
|
||||
const pickerFilters = (ext?: string[]) => {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
return [{ name: nativeT("desktop.dialog.files"), extensions: ext }]
|
||||
}
|
||||
|
||||
const pickedFiles = createPickedFileAuthorizations()
|
||||
|
||||
type Deps = {
|
||||
relaunch: () => void
|
||||
awaitInitialization: () => Promise<ServerReadyData>
|
||||
@@ -74,10 +53,8 @@ type Deps = {
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
|
||||
app.on("before-quit", () => drafts.flush())
|
||||
app.once("will-quit", () => drafts.close())
|
||||
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
|
||||
const files = createFileCapabilities()
|
||||
const storage = createDesktopStorage()
|
||||
|
||||
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
|
||||
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
|
||||
@@ -103,124 +80,29 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
deps.setNativeTranslations(bundle)
|
||||
})
|
||||
handle(Ipc.storage.get, (_event, name, key) => {
|
||||
try {
|
||||
const store = getStore(name)
|
||||
const value = store.get(key)
|
||||
if (value === undefined || value === null) return null
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
handle(Ipc.storage.set, (_event, name, key, value) => {
|
||||
getStore(name).set(key, value)
|
||||
})
|
||||
handle(Ipc.storage.delete, (_event, name, key) => {
|
||||
getStore(name).delete(key)
|
||||
void removeStoreFileIfEmpty(name)
|
||||
})
|
||||
handle(Ipc.storage.clear, (_event, name) => {
|
||||
getStore(name).clear()
|
||||
void removeStoreFileIfEmpty(name)
|
||||
})
|
||||
handle(Ipc.storage.keys, (_event, name) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store)
|
||||
})
|
||||
handle(Ipc.storage.length, (_event, name) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store).length
|
||||
})
|
||||
handle(Ipc.drafts.get, (_event, key) => drafts.get(key))
|
||||
handle(Ipc.drafts.set, (_event, key, value) => drafts.set(key, value))
|
||||
handle(Ipc.drafts.delete, (_event, key) => drafts.set(key, null))
|
||||
handle(Ipc.drafts.putBlob, (_event, data) => drafts.putBlob(new Uint8Array(data)))
|
||||
handle(Ipc.drafts.getBlob, (_event, id) => {
|
||||
const data = drafts.getBlob(id)
|
||||
return data ? new Uint8Array(data).buffer : null
|
||||
return storage.get(name, key)
|
||||
})
|
||||
handle(Ipc.storage.set, (_event, name, key, value) => storage.set(name, key, value))
|
||||
handle(Ipc.storage.delete, (_event, name, key) => storage.deleteValue(name, key))
|
||||
handle(Ipc.storage.clear, (_event, name) => storage.clear(name))
|
||||
handle(Ipc.storage.keys, (_event, name) => storage.keys(name))
|
||||
handle(Ipc.storage.length, (_event, name) => storage.length(name))
|
||||
handle(Ipc.drafts.get, (_event, key) => storage.drafts.get(key))
|
||||
handle(Ipc.drafts.set, (_event, key, value) => storage.drafts.set(key, value))
|
||||
handle(Ipc.drafts.delete, (_event, key) => storage.drafts.set(key, null))
|
||||
handle(Ipc.drafts.putBlob, (_event, data) => storage.drafts.putBlob(data))
|
||||
handle(Ipc.drafts.getBlob, (_event, id) => storage.drafts.getBlob(id))
|
||||
|
||||
handle(Ipc.files.openDirectoryPicker, async (_event, opts) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||
})
|
||||
|
||||
handle(Ipc.files.openFilePicker, async (event, opts) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: opts?.title ?? nativeT("desktop.dialog.chooseFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
filters: pickerFilters(opts?.extensions),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (filePath) => ({
|
||||
path: filePath,
|
||||
name: basename(filePath),
|
||||
size: (await stat(filePath)).size,
|
||||
})),
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
const token = pickedFiles.add(event.sender.id, result.filePaths)
|
||||
return { token, files }
|
||||
})
|
||||
|
||||
handle(Ipc.files.readPickedFile, async (event, token, filePath) => {
|
||||
return pickedFiles.read(event.sender.id, token, filePath)
|
||||
})
|
||||
|
||||
handle(Ipc.files.releasePickedFiles, (event, token) => {
|
||||
pickedFiles.release(event.sender.id, token)
|
||||
})
|
||||
|
||||
handle(Ipc.files.saveFilePicker, async (_event, opts) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: opts?.title ?? nativeT("desktop.dialog.saveFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
})
|
||||
|
||||
on(Ipc.files.openExternal, (_event, url) => {
|
||||
openExternalURL(url)
|
||||
})
|
||||
|
||||
on(Ipc.files.openLocalFile, (_event, url) => {
|
||||
openLocalFileURL(url)
|
||||
})
|
||||
|
||||
handle(Ipc.files.openPath, async (_event, path, app) => {
|
||||
if (!app) return shell.openPath(path)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
|
||||
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
})
|
||||
|
||||
handle(Ipc.files.revealPath, async (_event, path) => {
|
||||
const exists = await stat(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (!exists) return false
|
||||
shell.showItemInFolder(path)
|
||||
return true
|
||||
})
|
||||
|
||||
handle(Ipc.files.readClipboardImage, () => {
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) return null
|
||||
const buffer = new Uint8Array(image.toPNG()).buffer
|
||||
const size = image.getSize()
|
||||
return { buffer, width: size.width, height: size.height }
|
||||
})
|
||||
handle(Ipc.files.openDirectoryPicker, (_event, options) => files.openDirectoryPicker(options))
|
||||
handle(Ipc.files.openFilePicker, (event, options) => files.openFilePicker(event.sender.id, options))
|
||||
handle(Ipc.files.readPickedFile, (event, token, path) => files.readPickedFile(event.sender.id, token, path))
|
||||
handle(Ipc.files.releasePickedFiles, (event, token) => files.releasePickedFiles(event.sender.id, token))
|
||||
handle(Ipc.files.saveFilePicker, (_event, options) => files.saveFilePicker(options))
|
||||
on(Ipc.files.openExternal, (_event, url) => openExternalURL(url))
|
||||
on(Ipc.files.openLocalFile, (_event, url) => openLocalFileURL(url))
|
||||
handle(Ipc.files.openPath, (_event, path, app) => files.openPath(path, app))
|
||||
handle(Ipc.files.revealPath, (_event, path) => files.revealPath(path))
|
||||
handle(Ipc.files.readClipboardImage, () => files.readClipboardImage())
|
||||
|
||||
handle(Ipc.window.getId, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
@@ -300,11 +182,3 @@ export function registerWslIpcHandlers(wsl: WslIpc) {
|
||||
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
|
||||
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
sendIpcEvent(win.webContents, Ipc.menu.command, id)
|
||||
}
|
||||
|
||||
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
|
||||
sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import http from "node:http"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import { app } from "electron"
|
||||
import contextMenu from "electron-context-menu"
|
||||
import { Effect } from "effect"
|
||||
import { CHANNEL, VERSION } from "../constants"
|
||||
import { initCrashReporter, initLogging, type DesktopLogger } from "../native/logging"
|
||||
import { getUserShell, loadShellEnv } from "../service/shell-env"
|
||||
import { cleanupStoreFiles } from "../storage/cleanup"
|
||||
import { registerRendererProtocol, setDockIcon } from "../windows"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const appIDs: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
export function configureApplication() {
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
const onboardingRoot = createOnboardingTestRoot()
|
||||
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appID)
|
||||
app.setPath("userData", onboardingRoot ? join(onboardingRoot, "desktop") : join(app.getPath("appData"), appID))
|
||||
if (onboardingRoot) app.setPath("sessionData", join(onboardingRoot, "session"))
|
||||
|
||||
initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
const logger = initLogging()
|
||||
initCrashReporter()
|
||||
loadSystemCertificates(logger)
|
||||
logger.log("app starting", {
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingRoot),
|
||||
})
|
||||
|
||||
loadProxyEnvironment(logger)
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
return logger
|
||||
}
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
export function preferApplicationEnvironment(logger: DesktopLogger) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? loadShellEnv(shell, logger) : null
|
||||
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
|
||||
Object.assign(process.env, {
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
})
|
||||
}
|
||||
|
||||
export function prepareDesktop(logger: DesktopLogger) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
if (result.deleted.length === 0) return
|
||||
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) => Effect.sync(() => logger.warn("failed to clean scoped store files", error))),
|
||||
)
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
})
|
||||
}
|
||||
|
||||
export function loadProxyEnvironment(logger: DesktopLogger) {
|
||||
ensureLoopbackNoProxy()
|
||||
try {
|
||||
// Electron 41.2 has a newer Node API than the current @types/node package.
|
||||
const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void }
|
||||
proxyAwareHttp.setGlobalProxyFromEnv()
|
||||
} catch (error) {
|
||||
logger.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
function createOnboardingTestRoot() {
|
||||
if (!testOnboarding) return undefined
|
||||
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = join(root, "state")
|
||||
return root
|
||||
}
|
||||
|
||||
function loadSystemCertificates(logger: DesktopLogger) {
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
;["NO_PROXY", "no_proxy"].forEach((key) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
loopback.forEach((host) => {
|
||||
if (!items.some((value) => value.toLowerCase() === host)) items.push(host)
|
||||
})
|
||||
process.env[key] = items.join(",")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import type { Event } from "electron"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { writeLog, type DesktopLogger } from "../native/logging"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
|
||||
export function createApplicationLifecycle(logger: DesktopLogger) {
|
||||
const pendingDeepLinks: string[] = []
|
||||
const wsl = { stop: async () => {} }
|
||||
const emitDeepLinks = (urls: string[]) => {
|
||||
if (!urls.length) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
|
||||
}
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
void wsl.stop().finally(() => {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
const win = getLastFocusedWindow()
|
||||
if (!win) return
|
||||
win.show()
|
||||
win.focus()
|
||||
})
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
app.on("before-quit", () => {
|
||||
setAppQuitting()
|
||||
void wsl.stop()
|
||||
})
|
||||
app.on("will-quit", () => {
|
||||
setAppQuitting()
|
||||
void wsl.stop()
|
||||
})
|
||||
app.on("child-process-gone", (_event, details) => {
|
||||
writeLog("utility", "child process gone", { details }, "error")
|
||||
})
|
||||
app.on("render-process-gone", (_event, webContents, details) => {
|
||||
writeLog("window", "app render process gone", { url: safeWebContentsURL(webContents), details }, "error")
|
||||
})
|
||||
setRelaunchHandler(relaunch)
|
||||
;(["SIGINT", "SIGTERM"] as const).forEach((signal) => {
|
||||
process.on(signal, () => {
|
||||
setAppQuitting()
|
||||
void wsl.stop().finally(() => app.quit())
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
relaunch,
|
||||
prepareToRestart: () => wsl.stop(),
|
||||
setWslShutdown(stop: () => Promise<void>) {
|
||||
wsl.stop = stop
|
||||
},
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
restoreWindows() {
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit()
|
||||
})
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) restoreMainWindows()
|
||||
})
|
||||
return restoreMainWindows()
|
||||
},
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -2,10 +2,10 @@ import { existsSync, readdirSync } from "node:fs"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { app } from "electron"
|
||||
import { getStore } from "./store"
|
||||
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys"
|
||||
import { write as writeLog } from "./logging"
|
||||
import { hasExistingAppState } from "./install-state"
|
||||
import { writeLog } from "../native/logging"
|
||||
import { hasExistingAppState } from "../storage/install-state"
|
||||
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
|
||||
const DEFAULT_PROJECT_DIR = "Default Project"
|
||||
|
||||
+15
-10
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, wri
|
||||
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
|
||||
import { dirname, join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { VERSION } from "./constants"
|
||||
import { VERSION } from "../constants"
|
||||
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
@@ -19,6 +19,7 @@ let netLogPath: string | undefined
|
||||
|
||||
let logger: MainLogger
|
||||
export const getLogger = () => logger
|
||||
export type DesktopLogger = ReturnType<typeof initLogging>
|
||||
|
||||
export function initLogging() {
|
||||
initRunDirectory()
|
||||
@@ -39,25 +40,29 @@ export function initCrashReporter() {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
app.setPath("crashDumps", dir)
|
||||
crashReporter.start({ uploadToServer: false, compress: true })
|
||||
write("crash", "crash reporter started", { path: dir })
|
||||
writeLog("crash", "crash reporter started", { path: dir })
|
||||
}
|
||||
|
||||
export async function startNetLog() {
|
||||
async function startNetLog() {
|
||||
if (netLog.currentlyLogging) return
|
||||
netLogPath = join(run, "network.netlog")
|
||||
await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
|
||||
write("network", "net log started", { path: netLogPath })
|
||||
writeLog("network", "net log started", { path: netLogPath })
|
||||
}
|
||||
|
||||
export function startNetworkLogging() {
|
||||
return startNetLog().catch((error) => logger.warn("failed to start net log", error))
|
||||
}
|
||||
|
||||
export async function exportDebugLogs() {
|
||||
const restartNetLog = netLog.currentlyLogging
|
||||
if (restartNetLog) {
|
||||
await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error }))
|
||||
await netLog.stopLogging().catch((error) => writeLog("network", "failed to stop net log", { error }))
|
||||
}
|
||||
|
||||
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
|
||||
try {
|
||||
write("main", "exporting debug logs", { output })
|
||||
writeLog("main", "exporting debug logs", { output })
|
||||
await writeZip(output, [
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
|
||||
...collect(root, "desktop"),
|
||||
@@ -68,12 +73,12 @@ export async function exportDebugLogs() {
|
||||
return output
|
||||
} finally {
|
||||
if (restartNetLog) {
|
||||
await startNetLog().catch((error) => write("network", "failed to restart net log", { error }))
|
||||
await startNetLog().catch((error) => writeLog("network", "failed to restart net log", { error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function write(
|
||||
export function writeLog(
|
||||
name: string,
|
||||
message: string,
|
||||
extra?: Record<string, unknown>,
|
||||
@@ -195,10 +200,10 @@ function initConsoleTransport() {
|
||||
return
|
||||
}
|
||||
|
||||
const write = log.transports.console.writeFn.bind(log.transports.console)
|
||||
const writeConsole = log.transports.console.writeFn.bind(log.transports.console)
|
||||
log.transports.console.writeFn = (options) => {
|
||||
try {
|
||||
write(options)
|
||||
writeConsole(options)
|
||||
} catch (err) {
|
||||
if (!isBrokenPipe(err)) throw err
|
||||
log.transports.console.level = false
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import { createMainWindow, updateTitlebar } from "./windows"
|
||||
import { createMainWindow, updateTitlebar } from "../windows"
|
||||
|
||||
export type DesktopMenuActionHandlers = Partial<{
|
||||
checkForUpdates: () => void
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
type DesktopMenuEntry,
|
||||
type DesktopMenuRole,
|
||||
} from "@opencode-ai/app/desktop-menu"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { openExternalURL } from "./windows"
|
||||
import { nativeT } from "./native-translations"
|
||||
import { UPDATER_ENABLED } from "../constants"
|
||||
import { openExternalURL } from "../files"
|
||||
import { runDesktopMenuAction } from "./menu-actions"
|
||||
import { nativeT } from "./translations"
|
||||
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
@@ -34,6 +35,10 @@ export function createMenu(deps: Deps) {
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
sendIpcEvent(win.webContents, Ipc.menu.command, id)
|
||||
}
|
||||
|
||||
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
|
||||
if (entry.type === "separator") return { type: "separator" }
|
||||
if (entry.role) return { role: nativeRole(entry.role), label: entry.labelKey ? nativeT(entry.labelKey) : undefined }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export const mainBundleRoot = dirname(fileURLToPath(import.meta.url))
|
||||
export const developmentResourcesRoot = join(mainBundleRoot, "../../resources")
|
||||
export const preloadPath = join(mainBundleRoot, "../preload/index.js")
|
||||
export const rendererRoot = join(mainBundleRoot, "../renderer")
|
||||
@@ -1,49 +0,0 @@
|
||||
import { getLogger } from "./logging"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
|
||||
export function setDefaultServerUrl(url: string | null) {
|
||||
if (url) {
|
||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
}
|
||||
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
|
||||
export function preferAppEnv() {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
|
||||
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
|
||||
Object.assign(process.env, {
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
const headers = new Headers()
|
||||
if (password) {
|
||||
const auth = Buffer.from(`opencode:${password}`).toString("base64")
|
||||
headers.set("authorization", `Basic ${auth}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL("/api/health", url), {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -3,14 +3,12 @@ import { execFile } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { chmod, copyFile, mkdir, readdir, rename, rm } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { parseCliVersion } from "./cli-version"
|
||||
import { developmentResourcesRoot } from "../paths"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
type Logger = {
|
||||
log(message: string, meta?: Record<string, unknown>): void
|
||||
error(message: string, meta?: Record<string, unknown>): void
|
||||
@@ -69,7 +67,7 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
async function resolveBundledCli(isolated: boolean, logger: Logger) {
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
|
||||
: join(developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseCliVersion(await run(bundled, ["--version"], logger))
|
||||
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
|
||||
@@ -0,0 +1,18 @@
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
const headers = new Headers()
|
||||
if (password) {
|
||||
const auth = Buffer.from(`opencode:${password}`).toString("base64")
|
||||
headers.set("authorization", `Basic ${auth}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL("/api/health", url), {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { DEFAULT_SERVER_URL_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
|
||||
export function setDefaultServerUrl(url: string | null) {
|
||||
if (url) {
|
||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
}
|
||||
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./store-cleanup"
|
||||
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup"
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createDesktopDraftStore } from "./draft-store"
|
||||
import { createDesktopDraftStore } from "./drafts"
|
||||
|
||||
test("flushes the latest buffered draft and stores blobs", () => {
|
||||
const store = createDesktopDraftStore(":memory:")
|
||||
@@ -0,0 +1,43 @@
|
||||
import { join } from "node:path"
|
||||
import { app } from "electron"
|
||||
import { createDesktopDraftStore } from "./drafts"
|
||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||
|
||||
export function createDesktopStorage() {
|
||||
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
|
||||
app.on("before-quit", () => drafts.flush())
|
||||
app.once("will-quit", () => drafts.close())
|
||||
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
|
||||
|
||||
return {
|
||||
get(name: string, key: string) {
|
||||
try {
|
||||
const value = getStore(name).get(key)
|
||||
if (value === undefined || value === null) return null
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
|
||||
deleteValue(name: string, key: string) {
|
||||
getStore(name).delete(key)
|
||||
void removeStoreFileIfEmpty(name)
|
||||
},
|
||||
clear(name: string) {
|
||||
getStore(name).clear()
|
||||
void removeStoreFileIfEmpty(name)
|
||||
},
|
||||
keys: (name: string) => Object.keys(getStore(name).store),
|
||||
length: (name: string) => Object.keys(getStore(name).store).length,
|
||||
drafts: {
|
||||
get: (key: string) => drafts.get(key),
|
||||
set: (key: string, value: string | null) => drafts.set(key, value),
|
||||
putBlob: (data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)),
|
||||
getBlob(id: string) {
|
||||
const data = drafts.getBlob(id)
|
||||
return data ? new Uint8Array(data).buffer : null
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import electron from "electron"
|
||||
import { rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { SETTINGS_STORE } from "./store-keys"
|
||||
import { deleteStoreFileIfEmpty } from "./store-cleanup"
|
||||
import { deleteStoreFileIfEmpty } from "./cleanup"
|
||||
import { SETTINGS_STORE } from "./keys"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./controller"
|
||||
|
||||
// Drives the controller the way the app does: start or check, observe the states
|
||||
// the renderer sees, then install like a button click. `calls` records the platform
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user